aboutsummaryrefslogtreecommitdiffstats
path: root/node-admin
diff options
context:
space:
mode:
authorHÃ¥kon Hallingstad <hakon@yahooinc.com>2022-01-11 18:36:30 +0100
committerGitHub <noreply@github.com>2022-01-11 18:36:30 +0100
commitdf27a41055cad92454324c9b4bfd94b19abfd15b (patch)
tree0a7a02f9b2a4ca85696f6aa816b5cae32b41ee4d /node-admin
parent1559ccd25b9bfe99798d2055ced75f67ac9ae79e (diff)
parentaa9682ca8cc0ceea89766549a6cf0ca548749b3a (diff)
Merge pull request #20720 from vespa-engine/hakonhall/file-templates
File templates
Diffstat (limited to 'node-admin')
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/BadTemplateException.java13
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Form.java86
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/FormBuilder.java81
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/IfSection.java68
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/ListSection.java54
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/LiteralSection.java26
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NameAlreadyExistsTemplateException.java22
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NoSuchNameTemplateException.java13
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NotBooleanValueTemplateException.java11
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Section.java30
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/SectionList.java68
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Template.java44
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateDescriptor.java30
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateException.java18
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFile.java20
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateNameNotSetException.java13
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateParser.java161
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Token.java60
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/VariableSection.java37
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/package-info.java5
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/Cursor.java165
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/CursorRange.java38
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/TextLocation.java30
-rw-r--r--node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFileTest.java73
-rw-r--r--node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateTest.java79
-rw-r--r--node-admin/src/test/resources/template1.tmp10
-rw-r--r--node-admin/src/test/resources/template2.tmp4
-rw-r--r--node-admin/src/test/resources/template3.tmp6
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-}