aboutsummaryrefslogtreecommitdiffstats
path: root/node-admin
diff options
context:
space:
mode:
authorHåkon Hallingstad <hakon@yahooinc.com>2022-01-07 14:34:11 +0100
committerHåkon Hallingstad <hakon@yahooinc.com>2022-01-07 15:26:01 +0100
commitf097589d3166ec545813a6bd52084c88974bfb9b (patch)
treeef44833b64eaf7dba392e688d8e50a336bf5908f /node-admin
parente89d9b57acfca329c7013b5ca4a6099b7bdffae3 (diff)
Adds a new template engine
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.java82
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/FormBuilder.java62
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/ListSection.java38
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/LiteralSection.java21
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NameAlreadyExistsTemplateException.java13
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NoSuchNameTemplateException.java11
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Section.java22
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Template.java85
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateDescriptor.java24
-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.java126
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Token.java71
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/VariableSection.java41
-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.java155
-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.java56
-rw-r--r--node-admin/src/test/resources/template1.tmp10
-rw-r--r--node-admin/src/test/resources/template2.tmp4
23 files changed, 958 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..2ac0e70ff7f
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Form.java
@@ -0,0 +1,82 @@
+// 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.List;
+import java.util.Map;
+
+/**
+ * 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 extends Section {
+ private final List<Section> sections;
+
+ private final Map<String, VariableSection> variables;
+ private final Map<String, ListSection> lists;
+
+ Form(CursorRange range, List<Section> sections, Map<String, VariableSection> variables, Map<String, ListSection> lists) {
+ super(range);
+ this.sections = List.copyOf(sections);
+ this.variables = Map.copyOf(variables);
+ this.lists = Map.copyOf(lists);
+ }
+
+ /** Set the value of a variable expression, e.g. %{=color}. */
+ public Form set(String name, String value) {
+ var section = variables.get(name);
+ if (section == null) {
+ throw new NoSuchNameTemplateException(this, name);
+ }
+ section.set(value);
+ return this;
+ }
+
+ 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);
+ }
+
+ /**
+ * Append a list element form. A list section is declared as follows:
+ *
+ * <pre>
+ * %{list name}...
+ * %{end}
+ * </pre>
+ *
+ * <p>The body between %{list name} and %{end} is instantiated as a form, and appended after
+ * any previously added forms.</p>
+ *
+ * @return the added form
+ */
+ public Form add(String name) {
+ var section = lists.get(name);
+ if (section == null) {
+ throw new NoSuchNameTemplateException(this, name);
+ }
+ return section.add();
+ }
+
+ public String render() {
+ var buffer = new StringBuilder((int) (range().length() * 1.2 + 128));
+ appendTo(buffer);
+ return buffer.toString();
+ }
+
+ @Override
+ public void appendTo(StringBuilder buffer) {
+ sections.forEach(section -> section.appendTo(buffer));
+ }
+}
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..965d0b6496a
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/FormBuilder.java
@@ -0,0 +1,62 @@
+// 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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+/**
+ * @author hakonhall
+ */
+class FormBuilder {
+ private final List<Section> sections = new ArrayList<>();
+ private final Map<String, VariableSection> variables = new HashMap<>();
+ private final Map<String, ListSection> lists = new HashMap<>();
+ private final CursorRange range;
+
+ static Form build(CursorRange range, List<Consumer<FormBuilder>> sections) {
+ var builder = new FormBuilder(range);
+ sections.forEach(section -> section.accept(builder));
+ return builder.build();
+ }
+
+ private FormBuilder(CursorRange range) {
+ this.range = new CursorRange(range);
+ }
+
+ FormBuilder addLiteralSection(CursorRange range) {
+ sections.add(new LiteralSection(range));
+ return this;
+ }
+
+ FormBuilder addVariableSection(CursorRange range, String name, Cursor nameOffset) {
+ checkNameIsAvailable(name, range);
+ var section = new VariableSection(range, name, nameOffset);
+ sections.add(section);
+ variables.put(section.name(), section);
+ return this;
+ }
+
+ FormBuilder addListSection(CursorRange range, String name, Template body) {
+ checkNameIsAvailable(name, range);
+ var section = new ListSection(range, name, body);
+ sections.add(section);
+ lists.put(section.name(), section);
+ return this;
+ }
+
+ private Form build() {
+ return new Form(range, sections, variables, lists);
+ }
+
+ private void checkNameIsAvailable(String name, CursorRange range) {
+ if (variables.containsKey(name) || lists.containsKey(name)) {
+ throw new NameAlreadyExistsTemplateException(name, range);
+ }
+ }
+}
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..4914bdd7de8
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/ListSection.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.template;
+
+import com.yahoo.vespa.hosted.node.admin.task.util.text.CursorRange;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a template list section
+ *
+ * @see Template
+ * @author hakonhall
+ */
+class ListSection extends Section {
+ private final Template body;
+ private final String name;
+ private final List<Form> elements = new ArrayList<>();
+
+ ListSection(CursorRange range, String name, Template body) {
+ super(range);
+ this.name = name;
+ this.body = body;
+ }
+
+ String name() { return name; }
+
+ Form add() {
+ var form = body.instantiate();
+ elements.add(form);
+ return form;
+ }
+
+ @Override
+ void appendTo(StringBuilder buffer) {
+ elements.forEach(form -> form.appendTo(buffer));
+ }
+}
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..852056fa283
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/LiteralSection.java
@@ -0,0 +1,21 @@
+// 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);
+ }
+}
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..cea53f39f25
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NameAlreadyExistsTemplateException.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 NameAlreadyExistsTemplateException extends TemplateException {
+ public NameAlreadyExistsTemplateException(String name, CursorRange range) {
+ super("Name '" + name + "' already exists in the " + describeSection(range));
+ }
+}
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..f6d630846a2
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NoSuchNameTemplateException.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 NoSuchNameTemplateException extends TemplateException {
+ public NoSuchNameTemplateException(Section section, String name) {
+ super("No such element '" + name + "' in the " + describeSection(section.range()));
+ }
+}
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..c0d9e651484
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Section.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.CursorRange;
+
+/**
+ * A section of a template text.
+ *
+ * @see Template
+ * @author hakonhall
+ */
+abstract class Section {
+ private final CursorRange range;
+
+ protected Section(CursorRange range) {
+ this.range = range;
+ }
+
+ protected CursorRange range() { return range; }
+
+ abstract void appendTo(StringBuilder buffer);
+}
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..3931202c24f
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Template.java
@@ -0,0 +1,85 @@
+// 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.HashSet;
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * 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: %{=identifier}
+ * list: %{list identifier}template%{end}
+ * identifier: # a valid Java identifier
+ * </pre>
+ *
+ * <p>Any newline (\n) following a non-variable directive is removed.</p>
+ *
+ * <p><b>Instantiate</b> the template to get a form ({@link #instantiate()}), fill it
+ * (e.g. {@link Form#set(String, String) Form.set()}), and render the String ({@link Form#render()}).</p>
+ *
+ * @see Form
+ * @see TemplateParser
+ * @see TemplateFile
+ * @author hakonhall
+ */
+public class Template {
+ private final Cursor start;
+ private final Cursor end;
+
+ private final List<Consumer<FormBuilder>> sections = new ArrayList<>();
+ private final HashSet<String> names = new HashSet<>();
+
+ Template(Cursor start) {
+ this.start = new Cursor(start);
+ this.end = new Cursor(start);
+ }
+
+ public Form instantiate() {
+ return FormBuilder.build(range(), sections);
+ }
+
+ void appendLiteralSection(Cursor end) {
+ CursorRange range = verifyAndUpdateEnd(end);
+ sections.add((FormBuilder builder) -> builder.addLiteralSection(range));
+ }
+
+ void appendVariableSection(String name, Cursor nameOffset, Cursor end) {
+ CursorRange range = verifyAndUpdateEnd(end);
+ verifyAndAddNewName(name, nameOffset);
+ sections.add(formBuilder -> formBuilder.addVariableSection(range, name, nameOffset));
+ }
+
+ void appendListSection(String name, Cursor nameCursor, Cursor end, Template body) {
+ CursorRange range = verifyAndUpdateEnd(end);
+ verifyAndAddNewName(name, nameCursor);
+ sections.add(formBuilder -> formBuilder.addListSection(range, name, body));
+ }
+
+ private CursorRange range() { return new CursorRange(start, end); }
+
+ private CursorRange verifyAndUpdateEnd(Cursor newEnd) {
+ var range = new CursorRange(this.end, newEnd);
+ this.end.set(newEnd);
+ return range;
+ }
+
+ private String verifyAndAddNewName(String name, Cursor nameOffset) {
+ if (!names.add(name)) {
+ throw new IllegalArgumentException("'" + name + "' at " +
+ nameOffset.calculateLocation().lineAndColumnText() +
+ " has already been defined");
+ }
+ return name;
+ }
+}
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..afdc3efc553
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateDescriptor.java
@@ -0,0 +1,24 @@
+// 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() {}
+
+ /** 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..402d7f8c5e5
--- /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 TemplateParser.parse(descriptor, content);
+ }
+}
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..7a8693ca4ad
--- /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(Section section, String name, Cursor nameOffset) {
+ super(name + " at " + nameOffset.calculateLocation().lineAndColumnText() + " has not been set");
+ }
+}
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..c1dfc4a775b
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateParser.java
@@ -0,0 +1,126 @@
+// 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;
+
+/**
+ * Parses a template String, see {@link Template} for details.
+ *
+ * @author hakonhall
+ */
+public class TemplateParser {
+ private final TemplateDescriptor descriptor;
+ private final Cursor start;
+ private final Cursor current;
+ private final Template template;
+ private final FormEndsIn formEndsIn;
+
+ public static Template parse(TemplateDescriptor descriptor, String text) {
+ return parse(descriptor, new Cursor(text), FormEndsIn.EOT).template();
+ }
+
+ private static TemplateParser parse(TemplateDescriptor descriptor, Cursor start, FormEndsIn formEndsIn) {
+ var parser = new TemplateParser(descriptor, start, formEndsIn);
+ parser.parse();
+ return parser;
+ }
+
+ enum FormEndsIn { EOT, END }
+
+ TemplateParser(TemplateDescriptor descriptor, Cursor start, FormEndsIn formEndsIn) {
+ this.descriptor = descriptor;
+ this.start = new Cursor(start);
+ this.current = new Cursor(start);
+ this.template = new Template(start);
+ this.formEndsIn = formEndsIn;
+ }
+
+ CursorRange range() { return new CursorRange(start, current); }
+ Template template() { return template; }
+
+ private void parse() {
+ do {
+ current.advanceTo(descriptor.startDelimiter());
+ if (!current.equals(start)) {
+ template.appendLiteralSection(current);
+ }
+
+ if (current.eot()) return;
+
+ if (!parseSection()) return;
+ } while (true);
+ }
+
+ /** Returns true if end was reached (according to formEndsIn). */
+ private boolean parseSection() {
+ current.skip(descriptor.startDelimiter());
+
+ if (current.skip('=')) {
+ parseExpression();
+ } else {
+ var startOfType = new Cursor(current);
+ String type = skipId().orElseThrow(() -> new BadTemplateException(current, "Missing section name"));
+
+ switch (type) {
+ case "end":
+ if (formEndsIn == FormEndsIn.EOT)
+ throw new BadTemplateException(startOfType, "Extraneous 'end'");
+ parseEndAttribute();
+ return false;
+ case "list":
+ parseListSection();
+ break;
+ default:
+ throw new BadTemplateException(startOfType, "Unknown section '" + type + "'");
+ }
+ }
+
+ return !current.eot();
+ }
+
+ private void parseExpression() {
+ var nameStart = new Cursor(current);
+ String name = parseId();
+ parseEndDelimiter(false);
+ template.appendVariableSection(name, nameStart, current);
+ }
+
+ private void parseEndAttribute() {
+ parseEndDelimiter(true);
+ }
+
+ private void parseListSection() {
+ skipWhitespace();
+ var startOfName = new Cursor(current);
+ String name = parseId();
+ parseEndDelimiter(true);
+
+ TemplateParser bodyParser = parse(descriptor, current, FormEndsIn.END);
+ current.set(bodyParser.current);
+
+ template.appendListSection(name, startOfName, current, bodyParser.template());
+ }
+
+ private void skipWhitespace() {
+ if (!current.skipWhitespace()) {
+ 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 void parseEndDelimiter(boolean removeFollowingNewline) {
+ if (!current.skip(descriptor.endDelimiter())) {
+ throw new BadTemplateException(current, "Expected section end (" + descriptor.endDelimiter() + ")");
+ }
+
+ if (removeFollowingNewline) current.skip('\n');
+ }
+}
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..4dd40091e78
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Token.java
@@ -0,0 +1,71 @@
+// 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 Optional<String> skipId(Cursor cursor) {
+ if (cursor.eot() || !isIdStart(cursor.getChar())) return Optional.empty();
+
+ Cursor start = new Cursor(cursor);
+ cursor.advance(1);
+
+ while (!cursor.eot() && isIdPart(cursor.getChar()))
+ cursor.advance(1);
+
+ return Optional.of(new CursorRange(start, cursor).string());
+ }
+
+ static String verifyId(String id) {
+ if (id.isEmpty())
+ throw new IllegalArgumentException("Token is empty");
+
+ if (!isIdStart(id.charAt(0)))
+ throw new IllegalArgumentException("Invalid identifier: '" + id + "'");
+
+ for (int i = 1; i < id.length(); ++i) {
+ if (!isIdPart(id.charAt(i)))
+ throw new IllegalArgumentException("Invalid identifier: '" + id + "'");
+ }
+
+ return id;
+ }
+
+ /** 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..e5745483b31
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/VariableSection.java
@@ -0,0 +1,41 @@
+// 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.Objects;
+
+/**
+ * Represents a template variable section
+ *
+ * @see Template
+ * @author hakonhall
+ */
+class VariableSection extends Section {
+ private final String name;
+ private final Cursor nameOffset;
+
+ private String value = null;
+
+ VariableSection(CursorRange range, String name, Cursor nameOffset) {
+ super(range);
+ this.name = name;
+ this.nameOffset = nameOffset;
+ }
+
+ String name() { return name; }
+
+ void set(String value) {
+ this.value = Objects.requireNonNull(value);
+ }
+
+ @Override
+ void appendTo(StringBuilder buffer) {
+ if (value == null) {
+ throw new TemplateNameNotSetException(this, name, nameOffset);
+ }
+
+ buffer.append(value);
+ }
+}
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..bc0fa642339
--- /dev/null
+++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/Cursor.java
@@ -0,0 +1,155 @@
+// 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 int offset() { return offset; }
+ 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); }
+ public char getChar() { return text.charAt(offset); }
+
+ /** The number of chars between pointer and EOT. */
+ public int length() { return text.length() - offset; }
+
+ public String fullText() { return text; }
+
+ /** 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;
+ }
+
+ /** If the next substring.length() chars of matches substring pointing at Advance pointer past substring if pointer Returns true if pointing at string. */
+ 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;
+ }
+ }
+
+ /** Returns true if at least one whitespace was skipped. */
+ public boolean skipWhitespace() {
+ if (!eot() && Character.isWhitespace(getChar())) {
+ do {
+ ++offset;
+ } while (!eot() && Character.isWhitespace(getChar()));
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /** Advance to the next char (if any) and return eot(). */
+ public boolean increment() {
+ if (eot()) return true;
+ ++offset;
+ return eot();
+ }
+
+ /** Advance {@code distance} chars and return {@link #eot()}. */
+ public boolean advance(int distance) {
+ this.offset = Math.max(0, Math.min(this.offset + distance, text.length()));
+ return eot();
+ }
+
+ /** 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
+ }
+ }
+
+ /** Advance pointer past needle (and return true), or to EOT (and return false). */
+ public boolean advancePast(String needle) {
+ if (advanceTo(needle)) {
+ offset += needle.length();
+ return true; // and eot() may or may not be true
+ } else {
+ return false; // and eot() is true
+ }
+ }
+
+ @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);
+ }
+
+ private void throwIfEot() { if (eot()) throw new StringIndexOutOfBoundsException(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..60330ecfb39
--- /dev/null
+++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFileTest.java
@@ -0,0 +1,56 @@
+// 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());
+ }
+
+ private Form getForm(String filename) {
+ return TemplateFile.read(Path.of("src/test/resources/" + filename)).instantiate();
+ }
+} \ No newline at end of file
diff --git a/node-admin/src/test/resources/template1.tmp b/node-admin/src/test/resources/template1.tmp
new file mode 100644
index 00000000000..a020dbb0739
--- /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 list 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..d36cb4a4a48
--- /dev/null
+++ b/node-admin/src/test/resources/template2.tmp
@@ -0,0 +1,4 @@
+%{list listA}body A
+%{list listB}body B
+%{end}
+%{end}