diff options
author | HÃ¥kon Hallingstad <hakon@yahooinc.com> | 2022-01-11 18:36:30 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-01-11 18:36:30 +0100 |
commit | df27a41055cad92454324c9b4bfd94b19abfd15b (patch) | |
tree | 0a7a02f9b2a4ca85696f6aa816b5cae32b41ee4d /node-admin | |
parent | 1559ccd25b9bfe99798d2055ced75f67ac9ae79e (diff) | |
parent | aa9682ca8cc0ceea89766549a6cf0ca548749b3a (diff) |
Merge pull request #20720 from vespa-engine/hakonhall/file-templates
File templates
Diffstat (limited to 'node-admin')
28 files changed, 1265 insertions, 0 deletions
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/BadTemplateException.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/BadTemplateException.java new file mode 100644 index 00000000000..65d7ebaa02d --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/BadTemplateException.java @@ -0,0 +1,13 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.Cursor; + +/** + * @author hakonhall + */ +public class BadTemplateException extends TemplateException { + public BadTemplateException(Cursor location, String message) { + super(message + " at " + location.calculateLocation().lineAndColumnText()); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Form.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Form.java new file mode 100644 index 00000000000..6b38835e24f --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Form.java @@ -0,0 +1,86 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * A form is an instance of a template to be filled, e.g. values set for variable sections, etc. + * + * @see Template + * @author hakonhall + */ +public class Form { + private Form parent = null; + private final CursorRange range; + private final List<Section> sections; + + private final Map<String, String> values = new HashMap<>(); + private final Map<String, ListSection> lists; + + Form(CursorRange range, List<Section> sections, Map<String, ListSection> lists) { + this.range = new CursorRange(range); + this.sections = List.copyOf(sections); + this.lists = Map.copyOf(lists); + } + + void setParent(Form parent) { this.parent = parent; } + + /** Set the value of a variable, e.g. %{=color}. */ + public Form set(String name, String value) { + values.put(name, value); + return this; + } + + /** Set the value of a variable and/or if-condition. */ + public Form set(String name, boolean value) { return set(name, Boolean.toString(value)); } + + public Form set(String name, int value) { return set(name, Integer.toString(value)); } + public Form set(String name, long value) { return set(name, Long.toString(value)); } + + public Form set(String name, String format, String first, String... rest) { + var args = new Object[1 + rest.length]; + args[0] = first; + System.arraycopy(rest, 0, args, 1, rest.length); + var value = String.format(format, args); + + return set(name, value); + } + + /** Add an instance of a list section after any previously added (for the given name) */ + public Form add(String name) { + var section = lists.get(name); + if (section == null) { + throw new NoSuchNameTemplateException(range, name); + } + return section.add(); + } + + public String render() { + var buffer = new StringBuilder((int) (range.length() * 1.2 + 128)); + appendTo(buffer); + return buffer.toString(); + } + + public void appendTo(StringBuilder buffer) { + sections.forEach(section -> section.appendTo(buffer)); + } + + /** Returns a deep copy of this. No changes to this affects the returned form, and vice versa. */ + Form copy() { + var builder = new FormBuilder(range.start()); + sections.forEach(section -> section.appendCopyTo(builder.topLevelSectionList())); + return builder.build(); + } + + Optional<String> getVariableValue(String name) { + String value = values.get(name); + if (value != null) return Optional.of(value); + if (parent != null) return parent.getVariableValue(name); + return Optional.empty(); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/FormBuilder.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/FormBuilder.java new file mode 100644 index 00000000000..cae5279f68a --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/FormBuilder.java @@ -0,0 +1,81 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.Cursor; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author hakonhall + */ +class FormBuilder { + /** The top-level section list in this form. */ + private final SectionList sectionList; + private final List<Section> allSections = new ArrayList<>(); + private final Map<String, VariableSection> sampleVariables = new HashMap<>(); + private final Map<String, IfSection> sampleIfSections = new HashMap<>(); + private final Map<String, ListSection> lists = new HashMap<>(); + + FormBuilder(Cursor start) { + this.sectionList = new SectionList(start, this); + } + + SectionList topLevelSectionList() { return sectionList; } + + void addLiteralSection(LiteralSection section) { + allSections.add(section); + } + + void addVariableSection(VariableSection section) { + // It's OK if the same name is used in an if-directive (as long as the value is boolean, + // determined when set on a form). + + ListSection existing = lists.get(section.name()); + if (existing != null) + throw new NameAlreadyExistsTemplateException(section.name(), existing.nameOffset(), + section.nameOffset()); + + sampleVariables.put(section.name(), section); + allSections.add(section); + } + + void addIfSection(IfSection section) { + // It's OK if the same name is used in a variable section (as long as the value is boolean, + // determined when set on a form). + + ListSection list = lists.get(section.name()); + if (list != null) + throw new NameAlreadyExistsTemplateException(section.name(), list.nameOffset(), + section.nameOffset()); + + sampleIfSections.put(section.name(), section); + allSections.add(section); + } + + void addListSection(ListSection section) { + VariableSection variableSection = sampleVariables.get(section.name()); + if (variableSection != null) + throw new NameAlreadyExistsTemplateException(section.name(), variableSection.nameOffset(), + section.nameOffset()); + + IfSection ifSection = sampleIfSections.get(section.name()); + if (ifSection != null) + throw new NameAlreadyExistsTemplateException(section.name(), ifSection.nameOffset(), + section.nameOffset()); + + ListSection previous = lists.put(section.name(), section); + if (previous != null) + throw new NameAlreadyExistsTemplateException(section.name(), previous.nameOffset(), + section.nameOffset()); + allSections.add(section); + } + + Form build() { + var form = new Form(sectionList.range(), sectionList.sections(), lists); + allSections.forEach(section -> section.setForm(form)); + return form; + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/IfSection.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/IfSection.java new file mode 100644 index 00000000000..8775e764b4f --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/IfSection.java @@ -0,0 +1,68 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.Cursor; +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +import java.util.Optional; + +/** + * @author hakonhall + */ +class IfSection extends Section { + private final boolean negated; + private final String name; + private final Cursor nameOffset; + private final SectionList ifSections; + private final Optional<SectionList> elseSections; + + IfSection(CursorRange range, boolean negated, String name, Cursor nameOffset, + SectionList ifSections, Optional<SectionList> elseSections) { + super(range); + this.negated = negated; + this.name = name; + this.nameOffset = nameOffset; + this.ifSections = ifSections; + this.elseSections = elseSections; + } + + String name() { return name; } + Cursor nameOffset() { return nameOffset; } + + @Override + void appendTo(StringBuilder buffer) { + Optional<String> stringValue = form().getVariableValue(name); + if (stringValue.isEmpty()) + throw new TemplateNameNotSetException(name, nameOffset); + + final boolean value; + if (stringValue.get().equals("true")) { + value = true; + } else if (stringValue.get().equals("false")) { + value = false; + } else { + throw new NotBooleanValueTemplateException(name); + } + + boolean condition = negated ? !value : value; + if (condition) { + ifSections.sections().forEach(section -> section.appendTo(buffer)); + } else if (elseSections.isPresent()) { + elseSections.get().sections().forEach(section -> section.appendTo(buffer)); + } + } + + @Override + void appendCopyTo(SectionList sectionList) { + SectionList ifSectionCopy = new SectionList(ifSections.range().start(), sectionList.formBuilder()); + ifSections.sections().forEach(section -> section.appendCopyTo(ifSectionCopy)); + + Optional<SectionList> elseSectionCopy = elseSections.map(elseSections2 -> { + SectionList elseSectionCopy2 = new SectionList(elseSections2.range().start(), sectionList.formBuilder()); + elseSections2.sections().forEach(section -> section.appendCopyTo(elseSectionCopy2)); + return elseSectionCopy2; + }); + + sectionList.appendIfSection(negated, name, nameOffset, range().end(), ifSectionCopy, elseSectionCopy); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/ListSection.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/ListSection.java new file mode 100644 index 00000000000..bc68cf96153 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/ListSection.java @@ -0,0 +1,54 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.Cursor; +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author hakonhall + */ +class ListSection extends Section { + private final String name; + private final Cursor nameOffset; + private final Form body; + private final List<Form> elements = new ArrayList<>(); + + ListSection(CursorRange range, String name, Cursor nameOffset, Form body) { + super(range); + this.name = name; + this.nameOffset = new Cursor(nameOffset); + this.body = body; + } + + String name() { return name; } + Cursor nameOffset() { return new Cursor(nameOffset); } + + @Override + void setForm(Form form) { + super.setForm(form); + body.setParent(form); + } + + Form add() { + Form element = body.copy(); + element.setParent(form()); + elements.add(element); + return element; + } + + @Override + void appendTo(StringBuilder buffer) { + elements.forEach(form -> form.appendTo(buffer)); + } + + @Override + void appendCopyTo(SectionList sectionList) { + // avoid copying elements for now + // Optimization: Reuse body in copy, since it is only used for copying. + + sectionList.appendListSection(name, nameOffset, range().end(), body); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/LiteralSection.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/LiteralSection.java new file mode 100644 index 00000000000..c03653253af --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/LiteralSection.java @@ -0,0 +1,26 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +/** + * Represents a template literal section + * + * @see Template + * @author hakonhall + */ +class LiteralSection extends Section { + LiteralSection(CursorRange range) { + super(range); + } + + @Override + void appendTo(StringBuilder buffer) { + range().appendTo(buffer); + } + + @Override + void appendCopyTo(SectionList sectionList) { + sectionList.appendLiteralSection(range().end()); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NameAlreadyExistsTemplateException.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NameAlreadyExistsTemplateException.java new file mode 100644 index 00000000000..dd92af14609 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NameAlreadyExistsTemplateException.java @@ -0,0 +1,22 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.Cursor; +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +/** + * @author hakonhall + */ +public class NameAlreadyExistsTemplateException extends TemplateException { + public NameAlreadyExistsTemplateException(String name, CursorRange range) { + super("Name '" + name + "' already exists in the " + describeSection(range)); + } + + public NameAlreadyExistsTemplateException(String name, Cursor firstNameLocation, + Cursor secondNameLocation) { + super("Section named '" + name + "' at " + + firstNameLocation.calculateLocation().lineAndColumnText() + + " conflicts with earlier section with the same name at " + + secondNameLocation.calculateLocation().lineAndColumnText()); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NoSuchNameTemplateException.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NoSuchNameTemplateException.java new file mode 100644 index 00000000000..706d347d39d --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NoSuchNameTemplateException.java @@ -0,0 +1,13 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +/** + * @author hakonhall + */ +public class NoSuchNameTemplateException extends TemplateException { + public NoSuchNameTemplateException(CursorRange range, String name) { + super("No such element '" + name + "' in the " + describeSection(range)); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NotBooleanValueTemplateException.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NotBooleanValueTemplateException.java new file mode 100644 index 00000000000..6c6d157bb47 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NotBooleanValueTemplateException.java @@ -0,0 +1,11 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +/** + * @author hakonhall + */ +public class NotBooleanValueTemplateException extends TemplateException { + public NotBooleanValueTemplateException(String name) { + super(name + " was set to a non-boolean value: must be true or false"); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Section.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Section.java new file mode 100644 index 00000000000..234915770f8 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Section.java @@ -0,0 +1,30 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +/** + * A section of a template text. + * + * @see Template + * @author hakonhall + */ +abstract class Section { + private final CursorRange range; + private Form form; + + protected Section(CursorRange range) { + this.range = range; + } + + void setForm(Form form) { this.form = form; } + + /** Guaranteed to return non-null after FormBuilder::build() returns. */ + protected Form form() { return form; } + + protected CursorRange range() { return range; } + + abstract void appendTo(StringBuilder buffer); + + abstract void appendCopyTo(SectionList sectionList); +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/SectionList.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/SectionList.java new file mode 100644 index 00000000000..b9a8c4e8c41 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/SectionList.java @@ -0,0 +1,68 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.Cursor; +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * A mutable list of sections at the same level that can be used to build a form, e.g. the if-body. + * + * @author hakonhall + */ +class SectionList { + private final Cursor start; + private final Cursor end; + private final FormBuilder formBuilder; + + private final List<Section> sections = new ArrayList<>(); + + SectionList(Cursor start, FormBuilder formBuilder) { + this.start = new Cursor(start); + this.end = new Cursor(start); + this.formBuilder = formBuilder; + } + + CursorRange range() { return new CursorRange(start, end); } + FormBuilder formBuilder() { return formBuilder; } + List<Section> sections() { return List.copyOf(sections); } + + void appendLiteralSection(Cursor end) { + CursorRange range = verifyAndUpdateEnd(end); + var section = new LiteralSection(range); + formBuilder.addLiteralSection(section); + sections.add(section); + } + + VariableSection appendVariableSection(String name, Cursor nameOffset, Cursor end) { + CursorRange range = verifyAndUpdateEnd(end); + var section = new VariableSection(range, name, nameOffset); + formBuilder.addVariableSection(section); + sections.add(section); + return section; + } + + void appendIfSection(boolean negated, String name, Cursor nameOffset, Cursor end, + SectionList ifSections, Optional<SectionList> elseSections) { + CursorRange range = verifyAndUpdateEnd(end); + var section = new IfSection(range, negated, name, nameOffset, ifSections, elseSections); + formBuilder.addIfSection(section); + sections.add(section); + } + + void appendListSection(String name, Cursor nameOffset, Cursor end, Form body) { + CursorRange range = verifyAndUpdateEnd(end); + var section = new ListSection(range, name, nameOffset, body); + formBuilder.addListSection(section); + sections.add(section); + } + + private CursorRange verifyAndUpdateEnd(Cursor newEnd) { + var range = new CursorRange(this.end, newEnd); + this.end.set(newEnd); + return range; + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Template.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Template.java new file mode 100644 index 00000000000..344424c7946 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Template.java @@ -0,0 +1,44 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +/** + * The Java representation of a template text. + * + * <p>A template is a sequence of literal text and dynamic sections defined by %{...} directives:</p> + * + * <pre> + * template: section* + * section: literal | variable | list + * literal: plain text not containing %{ + * variable: %{=id} + * if: %{if [!]id}template[%{else}template]%{end} + * list: %{list id}template%{end} + * id: a valid Java identifier + * </pre> + * + * <p>If the directive's end delimiter (}) is preceded by a "-" char, then any newline (\n) + * immediately following the end delimiter is removed.</p> + * + * <p>To use the template create a form ({@link #newForm()}), fill the form (e.g. + * {@link Form#set(String, String) Form.set()}), and render the String ({@link Form#render()}).</p> + * + * @see Form + * @see TemplateFile + * @author hakonhall + */ +public class Template { + private final Form form; + + public static Template from(String text) { return from(text, new TemplateDescriptor()); } + + public static Template from(String text, TemplateDescriptor descriptor) { + return TemplateParser.parse(text, descriptor).template(); + } + + Template(Form form) { + this.form = form; + } + + public Form newForm() { return form.copy(); } + +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateDescriptor.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateDescriptor.java new file mode 100644 index 00000000000..05d4f82d8d3 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateDescriptor.java @@ -0,0 +1,30 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +/** + * Specifies the how to interpret a template text. + * + * @author hakonhall + */ +public class TemplateDescriptor { + + private String startDelimiter = "%{"; + private String endDelimiter = "}"; + + public TemplateDescriptor() {} + + public TemplateDescriptor(TemplateDescriptor that) { + this.startDelimiter = that.startDelimiter; + this.endDelimiter = that.endDelimiter; + } + + /** Use these delimiters instead of the standard "%{" and "}" to start and end a template directive. */ + public TemplateDescriptor setDelimiters(String startDelimiter, String endDelimiter) { + this.startDelimiter = Token.verifyDelimiter(startDelimiter); + this.endDelimiter = Token.verifyDelimiter(endDelimiter); + return this; + } + + public String startDelimiter() { return startDelimiter; } + public String endDelimiter() { return endDelimiter; } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateException.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateException.java new file mode 100644 index 00000000000..f231583de52 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateException.java @@ -0,0 +1,18 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +/** + * @author hakonhall + */ +public class TemplateException extends RuntimeException { + public TemplateException(String message) { super(message); } + + protected static String describeSection(CursorRange range) { + var startLocation = range.start().calculateLocation(); + var endLocation = range.end().calculateLocation(); + return "template section starting at line " + startLocation.line() + " and column " + startLocation.column() + + ", and ending at line " + endLocation.line() + " and column " + endLocation.column(); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFile.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFile.java new file mode 100644 index 00000000000..0c1a26f4f65 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFile.java @@ -0,0 +1,20 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.file.UnixPath; + +import java.nio.file.Path; + +/** + * Parses a template file, see {@link Template} for details. + * + * @author hakonhall + */ +public class TemplateFile { + public static Template read(Path path) { return read(path, new TemplateDescriptor()); } + + public static Template read(Path path, TemplateDescriptor descriptor) { + String content = new UnixPath(path).readUtf8File(); + return Template.from(content, descriptor); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateNameNotSetException.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateNameNotSetException.java new file mode 100644 index 00000000000..d65c2a7c4d6 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateNameNotSetException.java @@ -0,0 +1,13 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.Cursor; + +/** + * @author hakonhall + */ +public class TemplateNameNotSetException extends TemplateException { + public TemplateNameNotSetException(String name, Cursor nameOffset) { + super("Variable at " + nameOffset.calculateLocation().lineAndColumnText() + " has not been set: " + name); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateParser.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateParser.java new file mode 100644 index 00000000000..93a83a3d29f --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateParser.java @@ -0,0 +1,161 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.Cursor; + +import java.util.EnumSet; +import java.util.Optional; + +/** + * Parses a template String, see {@link Template} for details. + * + * @author hakonhall + */ +class TemplateParser { + private final TemplateDescriptor descriptor; + private final Cursor start; + private final Cursor current; + private final FormBuilder formBuilder; + + static TemplateParser parse(String text, TemplateDescriptor descriptor) { + return parse(new TemplateDescriptor(descriptor), new Cursor(text), EnumSet.of(Sentinel.EOT)); + } + + private static TemplateParser parse(TemplateDescriptor descriptor, Cursor start, EnumSet<Sentinel> sentinel) { + var parser = new TemplateParser(descriptor, start); + parser.parse(parser.formBuilder.topLevelSectionList(), sentinel); + return parser; + } + + private enum Sentinel { ELSE, END, EOT } + + private TemplateParser(TemplateDescriptor descriptor, Cursor start) { + this.descriptor = descriptor; + this.start = new Cursor(start); + this.current = new Cursor(start); + this.formBuilder = new FormBuilder(start); + } + + Template template() { return new Template(formBuilder.build()); } + + private Sentinel parse(SectionList sectionList, EnumSet<Sentinel> sentinels) { + do { + current.advanceTo(descriptor.startDelimiter()); + if (!current.equals(start)) { + sectionList.appendLiteralSection(current); + } + + if (current.eot()) { + if (!sentinels.contains(Sentinel.EOT)) { + throw new BadTemplateException(current, + "Missing end directive for section started at " + + start.calculateLocation().lineAndColumnText()); + } + return Sentinel.EOT; + } + + Optional<Sentinel> sentinel = parseSection(sectionList, sentinels); + if (sentinel.isPresent()) return sentinel.get(); + } while (true); + } + + private Optional<Sentinel> parseSection(SectionList sectionList, EnumSet<Sentinel> sentinels) { + current.skip(descriptor.startDelimiter()); + + if (current.skip(Token.VARIABLE_DIRECTIVE_CHAR)) { + parseVariableSection(sectionList); + } else { + var startOfType = new Cursor(current); + String type = skipId().orElseThrow(() -> new BadTemplateException(current, "Missing section name")); + + switch (type) { + case "else": + if (!sentinels.contains(Sentinel.ELSE)) + throw new BadTemplateException(startOfType, "Extraneous 'else'"); + parseEndDirective(); + return Optional.of(Sentinel.ELSE); + case "end": + if (!sentinels.contains(Sentinel.END)) + throw new BadTemplateException(startOfType, "Extraneous 'end'"); + parseEndDirective(); + return Optional.of(Sentinel.END); + case "if": + parseIfSection(sectionList); + break; + case "list": + parseListSection(sectionList); + break; + default: + throw new BadTemplateException(startOfType, "Unknown section '" + type + "'"); + } + } + + return Optional.empty(); + } + + private void parseVariableSection(SectionList sectionList) { + var nameStart = new Cursor(current); + String name = parseId(); + parseEndDelimiter(true); + sectionList.appendVariableSection(name, nameStart, current); + } + + private void parseEndDirective() { + parseEndDelimiter(true); + } + + private void parseListSection(SectionList sectionList) { + skipRequiredWhitespaces(); + var startOfName = new Cursor(current); + String name = parseId(); + parseEndDelimiter(true); + + TemplateParser bodyParser = parse(descriptor, current, EnumSet.of(Sentinel.END)); + current.set(bodyParser.current); + + sectionList.appendListSection(name, startOfName, current, bodyParser.formBuilder.build()); + } + + private void parseIfSection(SectionList sectionList) { + skipRequiredWhitespaces(); + boolean negated = current.skip(Token.NEGATE_CHAR); + current.skipWhitespaces(); + var startOfName = new Cursor(current); + String name = parseId(); + parseEndDelimiter(true); + + SectionList ifSectionList = new SectionList(current, formBuilder); + Sentinel ifSentinel = parse(ifSectionList, EnumSet.of(Sentinel.ELSE, Sentinel.END)); + + Optional<SectionList> elseSectionList = Optional.empty(); + if (ifSentinel == Sentinel.ELSE) { + elseSectionList = Optional.of(new SectionList(current, formBuilder)); + parse(elseSectionList.get(), EnumSet.of(Sentinel.END)); + } + + sectionList.appendIfSection(negated, name, startOfName, current, ifSectionList, elseSectionList); + } + + private void skipRequiredWhitespaces() { + if (!current.skipWhitespaces()) { + throw new BadTemplateException(current, "Expected whitespace"); + } + } + + private String parseId() { + return skipId().orElseThrow(() -> new BadTemplateException(current, "Expected identifier")); + } + + private Optional<String> skipId() { return Token.skipId(current); } + + private boolean parseEndDelimiter(boolean skipNewline) { + boolean removeNewline = current.skip(Token.REMOVE_NEWLINE_CHAR); + if (!current.skip(descriptor.endDelimiter())) + throw new BadTemplateException(current, "Expected section end (" + descriptor.endDelimiter() + ")"); + + if (skipNewline && removeNewline) + current.skip('\n'); + + return removeNewline; + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Token.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Token.java new file mode 100644 index 00000000000..a83dab72025 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Token.java @@ -0,0 +1,60 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.Cursor; +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +import java.util.Optional; + +/** + * @author hakonhall + */ +class Token { + static final char NEGATE_CHAR = '!'; + static final char REMOVE_NEWLINE_CHAR = '-'; + static final char VARIABLE_DIRECTIVE_CHAR = '='; + + static Optional<String> skipId(Cursor cursor) { + if (cursor.eot() || !isIdStart(cursor.getChar())) return Optional.empty(); + + Cursor start = new Cursor(cursor); + cursor.increment(); + + while (!cursor.eot() && isIdPart(cursor.getChar())) + cursor.increment(); + + return Optional.of(new CursorRange(start, cursor).string()); + } + + /** A delimiter either starts a directive (e.g. %{) or ends it (e.g. }). */ + static String verifyDelimiter(String delimiter) { + if (!isAsciiToken(delimiter)) { + throw new IllegalArgumentException("Invalid delimiter: '" + delimiter + "'"); + } + return delimiter; + } + + /** Returns true for a non-empty string with only ASCII token characters. */ + private static boolean isAsciiToken(String string) { + if (string.isEmpty()) return false; + for (char c : string.toCharArray()) { + if (!isAsciiTokenChar(c)) return false; + } + return true; + } + + /** Returns true if char is a printable ASCII character except space (isgraph(3)). */ + private static boolean isAsciiTokenChar(char c) { + // 0x1F unit separator + // 0x20 space + // 0x21 ! + // ... + // 0x7E ~ + // 0x7F del + return 0x20 < c && c < 0x7F; + } + + // Our identifiers are equivalent to a Java identifiers. + private static boolean isIdStart(char c) { return Character.isJavaIdentifierStart(c); } + private static boolean isIdPart(char c) { return Character.isJavaIdentifierPart(c); } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/VariableSection.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/VariableSection.java new file mode 100644 index 00000000000..bf211a01190 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/VariableSection.java @@ -0,0 +1,37 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.vespa.hosted.node.admin.task.util.text.Cursor; +import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange; + +/** + * Represents a template variable section + * + * @see Template + * @author hakonhall + */ +class VariableSection extends Section { + private final String name; + private final Cursor nameOffset; + + VariableSection(CursorRange range, String name, Cursor nameOffset) { + super(range); + this.name = name; + this.nameOffset = nameOffset; + } + + String name() { return name; } + Cursor nameOffset() { return new Cursor(nameOffset); } + + @Override + void appendTo(StringBuilder buffer) { + String value = form().getVariableValue(name) + .orElseThrow(() -> new TemplateNameNotSetException(name, nameOffset)); + buffer.append(value); + } + + @Override + void appendCopyTo(SectionList sectionList) { + sectionList.appendVariableSection(name, nameOffset, range().end()); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/package-info.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/package-info.java new file mode 100644 index 00000000000..5bb8f656305 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/package-info.java @@ -0,0 +1,5 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +@ExportPackage +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/Cursor.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/Cursor.java new file mode 100644 index 00000000000..2fc3f8bac60 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/Cursor.java @@ -0,0 +1,165 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.text; + +import java.util.Objects; + +/** + * Cursor is a mutable offset into a fixed String, and useful for String parsing. + * + * @author hakonhall + */ +// @Mutable +public class Cursor { + private final String text; + private int offset; + private TextLocation locationCache; + + /** Creates a pointer to the first char of {@code text}, which is EOT if {@code text} is empty. */ + public Cursor(String text) { this(text, 0, new TextLocation()); } + + public Cursor(Cursor that) { this(that.text, that.offset, that.locationCache); } + + private Cursor(String text, int offset, TextLocation location) { + this.text = Objects.requireNonNull(text); + this.offset = offset; + this.locationCache = Objects.requireNonNull(location); + } + + /** Returns the substring of {@code text} starting at {@link #offset()} (to EOT). */ + @Override + public String toString() { return text.substring(offset); } + + public String fullText() { return text; } + public int offset() { return offset; } + public boolean bot() { return offset == 0; } + public boolean eot() { return offset == text.length(); } + public boolean startsWith(char c) { return offset < text.length() && text.charAt(offset) == c; } + public boolean startsWith(String prefix) { return text.startsWith(prefix, offset); } + + /** @throws IndexOutOfBoundsException if {@link #eot()}. */ + public char getChar() { return text.charAt(offset); } + + /** The number of chars between pointer and EOT. */ + public int length() { return text.length() - offset; } + + /** Calculate the current text location in O(length(text)). */ + public TextLocation calculateLocation() { + if (offset < locationCache.offset()) { + locationCache = new TextLocation(); + } else if (offset == locationCache.offset()) { + return locationCache; + } + + int lineIndex = locationCache.lineIndex(); + int columnIndex = locationCache.columnIndex(); + for (int i = locationCache.offset(); i < offset; ++i) { + if (text.charAt(i) == '\n') { + ++lineIndex; + columnIndex = 0; + } else { + ++columnIndex; + } + } + + locationCache = new TextLocation(offset, lineIndex, columnIndex); + return locationCache; + } + + public void set(Cursor that) { + if (that.text != text) { + throw new IllegalArgumentException("'that' doesn't refer to the same text"); + } + + this.offset = that.offset; + } + + /** Advance substring.length() if this startsWith the substring, returning true if so. */ + public boolean skip(String substring) { + if (startsWith(substring)) { + offset += substring.length(); + return true; + } else { + return false; + } + } + + public boolean skip(char c) { + if (startsWith(c)) { + ++offset; + return true; + } else { + return false; + } + } + + /** If the current char is a whitespace, skip it and return true. */ + public boolean skipWhitespace() { + if (!eot() && Character.isWhitespace(getChar())) { + ++offset; + return true; + } else { + return false; + } + } + + /** Returns true if at least one whitespace was skipped. */ + public boolean skipWhitespaces() { + if (skipWhitespace()) { + while (skipWhitespace()) + ++offset; + return true; + } else { + return false; + } + } + + /** Return false if eot(), otherwise advance to the next char and return true. */ + public boolean increment() { + if (eot()) return false; + ++offset; + return true; + } + + /** + * Advance {@code distance} chars until bot() or eot() is reached (distance may be negative), + * and return true if this cursor moved the full distance. + */ + public boolean advance(int distance) { + int newOffset = offset + distance; + if (newOffset < 0) { + this.offset = 0; + return false; + } else if (newOffset > text.length()) { + this.offset = text.length(); + return false; + } else { + this.offset = newOffset; + return true; + } + } + + /** Advance pointer until start of needle is found (and return true), or EOT is reached (and return false). */ + public boolean advanceTo(String needle) { + int index = text.indexOf(needle, offset); + if (index == -1) { + offset = text.length(); + return false; // and eot() is true + } else { + offset = index; + return true; // and eot() is false + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Cursor cursor = (Cursor) o; + return offset == cursor.offset && text.equals(cursor.text); + } + + @Override + public int hashCode() { + return Objects.hash(text, offset); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/CursorRange.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/CursorRange.java new file mode 100644 index 00000000000..23ac69ccee2 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/CursorRange.java @@ -0,0 +1,38 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.text; + +/** + * A start- and end- offset in an underlying String. + * + * @author hakonhall + */ +public class CursorRange { + private final Cursor start; + private final Cursor end; + + @SuppressWarnings("StringEquality") + public CursorRange(Cursor start, Cursor end) { + if (start.fullText() != end.fullText()) { + throw new IllegalArgumentException("start and end points to different texts"); + } + + if (start.offset() > end.offset()) { + throw new IllegalArgumentException("start offset " + start.offset() + + " is beyond end offset " + end.offset()); + } + + this.start = new Cursor(start); + this.end = new Cursor(end); + } + + public CursorRange(CursorRange that) { + this.start = new Cursor(that.start); + this.end = new Cursor(that.end); + } + + public Cursor start() { return new Cursor(start); } + public Cursor end() { return new Cursor(end); } + public int length() { return end.offset() - start.offset(); } + public String string() { return start.fullText().substring(start.offset(), end.offset()); } + public void appendTo(StringBuilder buffer) { buffer.append(start.fullText(), start.offset(), end.offset()); } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/TextLocation.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/TextLocation.java new file mode 100644 index 00000000000..32441c842b0 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/TextLocation.java @@ -0,0 +1,30 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.text; + +/** + * The location within an implied multi-line String. + * + * @author hakonhall + */ +//@Immutable +public class TextLocation { + private final int offset; + private final int lineIndex; + private final int columnIndex; + + public TextLocation() { this(0, 0, 0); } + + public TextLocation(int offset, int lineIndex, int columnIndex) { + this.offset = offset; + this.lineIndex = lineIndex; + this.columnIndex = columnIndex; + } + + public int offset() { return offset; } + public int lineIndex() { return lineIndex; } + public int line() { return lineIndex + 1; } + public int columnIndex() { return columnIndex; } + public int column() { return columnIndex + 1; } + + public String lineAndColumnText() { return "line " + line() + " and column " + column(); } +} diff --git a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFileTest.java b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFileTest.java new file mode 100644 index 00000000000..40913184a67 --- /dev/null +++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFileTest.java @@ -0,0 +1,73 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author hakonhall + */ +class TemplateFileTest { + @Test + void verifyVariableSection() { + Form form = getForm("template1.tmp"); + form.set("varname", "varvalue"); + assertEquals("variable section 'varvalue'\n" + + "end of text\n", form.render()); + } + + @Test + void verifySimpleListSection() { + Form form = getForm("template1.tmp"); + form.set("varname", "varvalue") + .add("listname") + .set("varname", "different varvalue") + .set("varname2", "varvalue2"); + assertEquals("variable section 'varvalue'\n" + + "same variable section 'different varvalue'\n" + + "different variable section 'varvalue2'\n" + + "between ends\n" + + "end of text\n", form.render()); + } + + @Test + void verifyNestedListSection() { + Form form = getForm("template2.tmp"); + Form A0 = form.add("listA"); + Form A0B0 = A0.add("listB"); + Form A0B1 = A0.add("listB"); + + Form A1 = form.add("listA"); + Form A1B0 = A1.add("listB"); + assertEquals("body A\n" + + "body B\n" + + "body B\n" + + "body A\n" + + "body B\n", + form.render()); + } + + @Test + void verifyVariableReferences() { + Form form = getForm("template3.tmp"); + form.set("varname", "varvalue") + .set("innerVarSetAtTop", "val2"); + form.add("l"); + form.add("l") + .set("varname", "varvalue2"); + assertEquals("varvalue\n" + + "varvalue\n" + + "inner varvalue\n" + + "val2\n" + + "inner varvalue2\n" + + "val2\n", + form.render()); + } + + private Form getForm(String filename) { + return TemplateFile.read(Path.of("src/test/resources/" + filename)).newForm(); + } +}
\ No newline at end of file diff --git a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateTest.java b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateTest.java new file mode 100644 index 00000000000..8d503dd4784 --- /dev/null +++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateTest.java @@ -0,0 +1,79 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.template; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author hakonhall + */ +public class TemplateTest { + @Test + void verifyNewlineRemoval() { + Form form = makeForm("a%{list a}\n" + + "b%{end}\n" + + "c%{list c-}\n" + + "d%{end-}\n" + + "e\n"); + form.add("a"); + form.add("c"); + + assertEquals("a\n" + + "b\n" + + "cde\n", + form.render()); + } + + @Test + void verifyIfSection() { + Template template = Template.from("Hello%{if cond} world%{end}!"); + assertEquals("Hello world!", template.newForm().set("cond", true).render()); + assertEquals("Hello!", template.newForm().set("cond", false).render()); + } + + @Test + void verifyComplexIfSection() { + Template template = Template.from("%{if cond-}\n" + + "var: %{=varname}\n" + + "if: %{if !inner}inner is false%{end}\n" + + "list: %{list formname}element%{end}\n" + + "%{end-}\n"); + + assertEquals("", template.newForm().set("cond", false).render()); + + assertEquals("var: varvalue\n" + + "if: \n" + + "list: \n", + template.newForm() + .set("cond", true) + .set("varname", "varvalue") + .set("inner", true) + .render()); + + Form form = template.newForm() + .set("cond", true) + .set("varname", "varvalue") + .set("inner", false); + form.add("formname"); + + assertEquals("var: varvalue\n" + + "if: inner is false\n" + + "list: element\n", form.render()); + } + + @Test + void verifyElse() { + var template = Template.from("%{if cond-}\n" + + "if body\n" + + "%{else-}\n" + + "else body\n" + + "%{end-}\n"); + assertEquals("if body\n", template.newForm().set("cond", true).render()); + assertEquals("else body\n", template.newForm().set("cond", false).render()); + } + + private Form makeForm(String templateText) { + return Template.from(templateText).newForm(); + } +} diff --git a/node-admin/src/test/resources/template1.tmp b/node-admin/src/test/resources/template1.tmp new file mode 100644 index 00000000000..3468709cc2e --- /dev/null +++ b/node-admin/src/test/resources/template1.tmp @@ -0,0 +1,10 @@ +variable section '%{=varname}' +%{list listname-} +same variable section '%{=varname}' +different variable section '%{=varname2}' +%{list innerlistname-} +inner form text +%{end-} +between ends +%{end-} +end of text diff --git a/node-admin/src/test/resources/template2.tmp b/node-admin/src/test/resources/template2.tmp new file mode 100644 index 00000000000..3bfa30ec7d3 --- /dev/null +++ b/node-admin/src/test/resources/template2.tmp @@ -0,0 +1,4 @@ +%{list listA-}body A +%{list listB-}body B +%{end-} +%{end-} diff --git a/node-admin/src/test/resources/template3.tmp b/node-admin/src/test/resources/template3.tmp new file mode 100644 index 00000000000..454b01761b5 --- /dev/null +++ b/node-admin/src/test/resources/template3.tmp @@ -0,0 +1,6 @@ +%{=varname} +%{=varname} +%{list l-} +inner %{=varname} +%{=innerVarSetAtTop} +%{end-} |