From f097589d3166ec545813a6bd52084c88974bfb9b Mon Sep 17 00:00:00 2001 From: HÃ¥kon Hallingstad Date: Fri, 7 Jan 2022 14:34:11 +0100 Subject: Adds a new template engine --- .../task/util/template/BadTemplateException.java | 13 ++ .../hosted/node/admin/task/util/template/Form.java | 82 +++++++++++ .../node/admin/task/util/template/FormBuilder.java | 62 +++++++++ .../node/admin/task/util/template/ListSection.java | 38 +++++ .../admin/task/util/template/LiteralSection.java | 21 +++ .../NameAlreadyExistsTemplateException.java | 13 ++ .../util/template/NoSuchNameTemplateException.java | 11 ++ .../node/admin/task/util/template/Section.java | 22 +++ .../node/admin/task/util/template/Template.java | 85 +++++++++++ .../task/util/template/TemplateDescriptor.java | 24 ++++ .../task/util/template/TemplateException.java | 18 +++ .../admin/task/util/template/TemplateFile.java | 20 +++ .../util/template/TemplateNameNotSetException.java | 13 ++ .../admin/task/util/template/TemplateParser.java | 126 +++++++++++++++++ .../node/admin/task/util/template/Token.java | 71 ++++++++++ .../admin/task/util/template/VariableSection.java | 41 ++++++ .../admin/task/util/template/package-info.java | 5 + .../hosted/node/admin/task/util/text/Cursor.java | 155 +++++++++++++++++++++ .../node/admin/task/util/text/CursorRange.java | 38 +++++ .../node/admin/task/util/text/TextLocation.java | 30 ++++ .../admin/task/util/template/TemplateFileTest.java | 56 ++++++++ node-admin/src/test/resources/template1.tmp | 10 ++ node-admin/src/test/resources/template2.tmp | 4 + 23 files changed, 958 insertions(+) create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/BadTemplateException.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Form.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/FormBuilder.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/ListSection.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/LiteralSection.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NameAlreadyExistsTemplateException.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/NoSuchNameTemplateException.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Section.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Template.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateDescriptor.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateException.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFile.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateNameNotSetException.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateParser.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/Token.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/VariableSection.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/template/package-info.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/Cursor.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/CursorRange.java create mode 100644 node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/text/TextLocation.java create mode 100644 node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/template/TemplateFileTest.java create mode 100644 node-admin/src/test/resources/template1.tmp create mode 100644 node-admin/src/test/resources/template2.tmp (limited to 'node-admin') 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
sections; + + private final Map variables; + private final Map lists; + + Form(CursorRange range, List
sections, Map variables, Map 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: + * + *
+     *     %{list name}...
+     *     %{end}
+     * 
+ * + *

The body between %{list name} and %{end} is instantiated as a form, and appended after + * any previously added forms.

+ * + * @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
sections = new ArrayList<>(); + private final Map variables = new HashMap<>(); + private final Map lists = new HashMap<>(); + private final CursorRange range; + + static Form build(CursorRange range, List> 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
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. + * + *

A template is a sequence of literal text and dynamic sections defined by %{...} directives:

+ * + *
+ *     template: section*
+ *     section: literal | variable | list
+ *     literal: # plain text not containing %{
+ *     variable: %{=identifier}
+ *     list: %{list identifier}template%{end}
+ *     identifier: # a valid Java identifier
+ * 
+ * + *

Any newline (\n) following a non-variable directive is removed.

+ * + *

Instantiate 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()}).

+ * + * @see Form + * @see TemplateParser + * @see TemplateFile + * @author hakonhall + */ +public class Template { + private final Cursor start; + private final Cursor end; + + private final List> sections = new ArrayList<>(); + private final HashSet 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 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 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} -- cgit v1.2.3