TemplateProcessor.java
package com.renomad.minum.templating;
import java.util.*;
import java.util.stream.Collectors;
import static com.renomad.minum.utils.SerializationUtils.tokenizer;
/**
* This class provides methods for working with templates.
* <p>
* The first step is to write a template. Here is an example:
* </p>
* <pre>
* Hello, my name is {{name}}
* </pre>
* <p>
* Then, feed that string into the {@link #buildProcessor} method, like
* this:
* </p>
* <pre>
* {@code
* String input = "Hello, my name is {{name}}"
* TemplateProcessor helloProcessor = TemplateProcessor.buildProcessor(input);
* }
* </pre>
* <p>
* The returned value ("helloProcessor") can be rendered with different values. For
* example:
* </p>
* <pre>
* {@code
* Map<String,String> myMap = Map.of("name", "Susanne");
* String fullyRenderedString = helloProcessor.renderTemplate(myMap);
* }
* </pre>
* <p>
* The result is:
* </p>
* <pre>
* {@code Hello, my name is Susanne}
* </pre>
*/
public final class TemplateProcessor {
/**
* template sections by indentation
*/
private Map<Integer, List<TemplateSection>> templatesSectionsByIndent;
private List<Map<String, String>> dataList = new ArrayList<>();
private final Set<String> keysFoundInTemplate;
private final Set<String> keysRegisteredForInnerTemplates;
private final String originalText;
/**
* This value is used to calculate a quick estimate of how many
* bytes of memory we will need for the buffer holding our generated string
*/
private final static double SIZE_ESTIMATE_MODIFIER = 1.1;
private final int estimatedSize;
private Map<String, TemplateProcessor> innerTemplates;
/**
* Instantiate a new object with a list of {@link TemplateSection}.
*/
private TemplateProcessor(List<TemplateSection> templateSections, String originalText) {
this.templatesSectionsByIndent = new HashMap<>();
this.templatesSectionsByIndent.put(0, templateSections);
keysFoundInTemplate = new HashSet<>();
keysRegisteredForInnerTemplates = new HashSet<>();
this.originalText = originalText;
this.innerTemplates = new HashMap<>();
estimatedSize = (int) Math.round(originalText.length() * SIZE_ESTIMATE_MODIFIER);
}
/**
* Given a map of key names -> value, render a template.
*/
public String renderTemplate(Map<String, String> myMap) {
registerData(List.of(myMap));
return internalRender(true).toString();
}
/**
* Given a list of maps of key names -> value, render a template
* multiple times.
*/
public String renderTemplate(List<Map<String, String>> myMap) {
registerData(myMap);
return internalRender(true).toString();
}
/**
* Recursively assembles the template and sub-templates
*/
public String renderTemplate() {
return internalRender(true).toString();
}
/**
* Render the template and any nested sub-templates. All templates
* must have data registered before running this method.
* @param runWithChecks Default: true. Check that there is a 1-to-1 correspondence between
* the keys provided and keys in the template and sub-templates, throwing
* an exception if there are any errors. Also check that the maps
* of data are consistent. This should be set true unless there is a reason
* to aim for maximum performance, which is actually not
* valuable in most cases, since the bottleneck is the business algorithms, database,
* and HTTP processing.
*/
public String renderTemplate(boolean runWithChecks) {
return internalRender(runWithChecks).toString();
}
/**
* Assign data. Keys must match to template.
*/
public void registerData(List<Map<String, String>> dataList) {
if (dataList == null){
throw new TemplateRenderException("provided data cannot be null");
} else if (dataList.isEmpty()) {
throw new TemplateRenderException("No data provided in registerData call");
}
this.dataList = dataList;
}
/**
* Builds a {@link TemplateProcessor} from a string
* containing a proper template. Templated values
* are surrounded by double-curly-braces, i.e. {{foo}} or {{ foo }}
*/
public static TemplateProcessor buildProcessor(String template) {
if (template == null || template.isEmpty()) {
throw new TemplateRenderException("The input to building a template must be a non-empty string");
}
var tp = new TemplateProcessor(new ArrayList<>(), template);
List<TemplateSection> tSections = tp.renderToTemplateSections(template);
Set<String> keysFound = tSections.stream()
.filter(x -> x.templateType.equals(TemplateType.DYNAMIC_TEXT))
.map(x -> x.key)
.collect(Collectors.toSet());
tp.keysFoundInTemplate.addAll(keysFound);
tp.templatesSectionsByIndent.put(0, tSections);
return tp;
}
private ArrayList<TemplateSection> renderToTemplateSections(String template) {
// this value holds the entire template after processing, comprised
// of an ordered list of TemplateSections
var tSections = new ArrayList<TemplateSection>();
// these values are used for logging and setting proper indentation
int rowNumber = 1;
int columnNumber = 1;
// this value records the indent of the beginning of template keys,
// so we can properly indent the values later.
int startOfKey = 0;
StringBuilder builder = new StringBuilder();
// this flag is to help us understand whether we are currently reading the
// name of a template literal.
// e.g. in the case of hello {{ name }}, "name" is the literal.
boolean isInsideTemplateKeyLiteral = false;
for (int i = 0; i < template.length(); i++) {
char charAtCursor = template.charAt(i);
if (charAtCursor == '{' && (i + 1) < template.length() && template.charAt(i + 1) == '{') {
isInsideTemplateKeyLiteral = true;
startOfKey = columnNumber - 1;
i += 1;
builder = processSectionInside(builder, tSections);
} else if (isInsideTemplateKeyLiteral && charAtCursor == '}' && (i + 1) < template.length() && template.charAt(i + 1) == '}') {
isInsideTemplateKeyLiteral = false;
i += 1;
builder = processSectionOutside(builder, tSections, startOfKey);
startOfKey = 0;
} else {
builder.append(charAtCursor);
/*
if we're at the end of the template, it's our last chance to
add a substring (we can't be adding to a key, since if we're
at the end, and it's not a closing brace, it's a malformed
template.
*/
if (i == template.length() - 1) {
if (isInsideTemplateKeyLiteral) {
// if we're exiting this string while inside a template literal, then
// we're reading a corrupted input, and we should make that clear
// to our caller.
String templateSample = template.length() > 10 ? template.substring(0, 10) + "..." : template;
throw new TemplateParseException(
"parsing failed for string starting with \"" + templateSample + "\" at line " + rowNumber + " and column " + columnNumber);
}
tSections.add(new TemplateSection(null, builder.toString(), null, TemplateType.STATIC_TEXT, 0));
}
}
if (charAtCursor == '\n') {
rowNumber += 1;
columnNumber = 1;
} else {
columnNumber += 1;
}
}
return tSections;
}
private static StringBuilder processSectionInside(StringBuilder builder, List<TemplateSection> tSections) {
if (!builder.isEmpty()) {
tSections.add(new TemplateSection(null, builder.toString(), null, TemplateType.STATIC_TEXT, 0));
builder = new StringBuilder();
}
return builder;
}
private static StringBuilder processSectionOutside(StringBuilder builder,
List<TemplateSection> tSections,
int indent) {
String key = builder.toString().trim();
tSections.add(new TemplateSection(key, "", null, TemplateType.DYNAMIC_TEXT, indent));
builder = new StringBuilder();
return builder;
}
/**
* Binds an inner template to a key of this template.
*/
public TemplateProcessor registerInnerTemplate(String key, TemplateProcessor innerTemplate) {
if (key == null || key.isBlank()) {
throw new TemplateRenderException("The key must be a valid non-blank string");
}
if (innerTemplate == null) {
throw new TemplateRenderException("The template must not be null");
}
if (this.equals(innerTemplate)) {
throw new TemplateRenderException("Disallowed to register a template to itself as an inner template");
}
if (keysRegisteredForInnerTemplates.contains(key)) {
throw new TemplateRenderException("key is already registered for use in another template: " + key);
}
// get the indent we should apply to each line after the first
// by seeing what indent exists in the template sections and
// creating a separate indented version for each one
Set<Integer> necessaryIndentations = this.templatesSectionsByIndent.get(0).stream()
.filter(x -> key.equals(x.key))
.map(x -> x.indent).collect(Collectors.toSet());
// make sure we have one for zero as well.
necessaryIndentations.add(0);
var copyOfInnerTemplate = new TemplateProcessor(innerTemplate.templatesSectionsByIndent.get(0), innerTemplate.getOriginalText());
copyOfInnerTemplate.keysFoundInTemplate.addAll(innerTemplate.keysFoundInTemplate);
copyOfInnerTemplate.keysRegisteredForInnerTemplates.addAll(innerTemplate.keysRegisteredForInnerTemplates);
copyOfInnerTemplate.innerTemplates = new HashMap<>(innerTemplate.innerTemplates);
this.innerTemplates.put(key, copyOfInnerTemplate);
copyOfInnerTemplate.templatesSectionsByIndent.clear();
// a non-configurable ceiling limit to avoid runaway loops
int MAXIMUM_LINES_ALLOWED = 10_000_000;
String originalText = copyOfInnerTemplate.getOriginalText();
List<String> lines = tokenizer(originalText, '\n', MAXIMUM_LINES_ALLOWED);
// if, after splitting on newlines, we have more than one line, we'll indent the remaining
// lines so that they end up at the same column as the first line.
for (int indentation : necessaryIndentations) {
var indentedInnerTemplateText = new StringBuilder(lines.getFirst());
for (int i = 1; i < lines.size(); i++) {
if (lines.get(i).isEmpty()) {
indentedInnerTemplateText.append('\n');
} else {
indentedInnerTemplateText.append('\n').append(" ".repeat(indentation)).append(lines.get(i));
}
}
List<TemplateSection> tSections = renderToTemplateSections(indentedInnerTemplateText.toString());
copyOfInnerTemplate.templatesSectionsByIndent.put(indentation, tSections);
// now, loop through all the template sections, replacing them appropriately with
// new data labeled as INNER_TEMPLATE.
Map<Integer, List<TemplateSection>> revisedTemplateSectionsByIndent = new HashMap<>();
for (var templateSectionsByIndent : templatesSectionsByIndent.entrySet()) {
List<TemplateSection> revisedList = new ArrayList<>();
for (TemplateSection templateSection : templateSectionsByIndent.getValue()) {
if (key.equals(templateSection.key)) {
revisedList.add(new TemplateSection(templateSection.key,
templateSection.staticData,
copyOfInnerTemplate,
TemplateType.INNER_TEMPLATE,
templateSection.indent));
} else {
revisedList.add(templateSection);
}
}
revisedTemplateSectionsByIndent.put(templateSectionsByIndent.getKey(), revisedList);
}
templatesSectionsByIndent = revisedTemplateSectionsByIndent;
this.keysRegisteredForInnerTemplates.add(key);
}
return copyOfInnerTemplate;
}
/**
* Returns the original unchanged template string
*/
public String getOriginalText() {
return originalText;
}
/**
* now, loop through the lists of data we were given, with the
* internal template sections in hand
*/
private StringBuilder internalRender(boolean runWithChecks) {
if (runWithChecks) {
correctnessCheck();
}
int capacity = calculateEstimatedSize();
StringBuilder parts = new StringBuilder(capacity);
return internalRender(0, parts);
}
/**
* This examines the currently registered data lists and template keys
* and confirms they are aligned. It will throw an exception if they
* are not perfectly correlated.
* <br>
*
*/
private void correctnessCheck() {
HashSet<String> copyOfKeysInTemplate = new HashSet<>(keysFoundInTemplate);
copyOfKeysInTemplate.removeAll(this.innerTemplates.keySet());
if (this.dataList.isEmpty()) {
if (!copyOfKeysInTemplate.isEmpty()) {
// at this point we know there is no data provided but the template
// requires data, so throw an exception.
throw new TemplateRenderException("No data was provided for these keys: " + copyOfKeysInTemplate);
}
} else {
// check for inconsistencies between maps in the data list
Set<String> keysInFirstMap = this.dataList.getFirst().keySet();
for (Map<String, String> data : this.dataList) {
if (!data.keySet().equals(keysInFirstMap)) {
Set<String> result = differenceBetweenSets(data.keySet(), keysInFirstMap);
throw new TemplateRenderException("In registered data, the maps were inconsistent on these keys: " + result);
}
}
// ensure consistency between the registered data and the template keys
HashSet<String> copyOfTemplateKeys = new HashSet<>(copyOfKeysInTemplate);
copyOfTemplateKeys.removeAll(keysInFirstMap);
if (!copyOfTemplateKeys.isEmpty()) {
throw new TemplateRenderException("These keys in the template were not provided data: " + copyOfTemplateKeys);
}
HashSet<String> copyOfDataKeys = new HashSet<>(keysInFirstMap);
copyOfDataKeys.removeAll(copyOfKeysInTemplate);
if (!copyOfDataKeys.isEmpty()) {
throw new TemplateRenderException("These keys in the data did not match anything in the template: " + copyOfDataKeys);
}
}
for (TemplateProcessor tp : this.innerTemplates.values()) {
tp.correctnessCheck();
}
}
private static Set<String> differenceBetweenSets(Set<String> set1, Set<String> set2) {
Set<String> union = new HashSet<>(set2);
union.addAll(set1);
Set<String> intersection = new HashSet<>(set2);
intersection.retainAll(set1);
Set<String> result = new HashSet<>(union);
result.removeAll(intersection);
return result;
}
/**
* build up a calculated size estimate for this and all
* nested templates.
*/
private int calculateEstimatedSize() {
// the size of the datalist specifies how many times we will render ourselves.
int sizeMultiplier = this.dataList.isEmpty() ? 1 : this.dataList.size();
int fullCalculatedSize = sizeMultiplier * estimatedSize;
for (TemplateProcessor innerProcessor : this.innerTemplates.values()) {
fullCalculatedSize += innerProcessor.calculateEstimatedSize();
}
return fullCalculatedSize;
}
private StringBuilder internalRender(int indent, StringBuilder parts) {
Map<String, String> myDataMap = Map.of();
List<TemplateSection> templateSections = templatesSectionsByIndent.get(indent);
int templateSectionsSize = templateSections.size();
int dataListIndex = 0;
if (!dataList.isEmpty()) {
myDataMap = dataList.get(dataListIndex);
}
// build ourself out for each map of data given
while (true) {
for (int i = 0; i < templateSectionsSize; i++) {
TemplateSection templateSection = templateSections.get(i);
switch (templateSection.templateType) {
case STATIC_TEXT -> parts.append(templateSection.staticData);
case DYNAMIC_TEXT -> parts.append(myDataMap.get(templateSection.key));
default -> templateSection.templateProcessor.internalRender(templateSection.indent, parts);
}
}
dataListIndex += 1;
if (!dataList.isEmpty() && dataListIndex < dataList.size()) {
myDataMap = dataList.get(dataListIndex);
parts.append("\n").repeat(" ", indent);
} else {
return parts;
}
}
}
/**
* Returns the reference to an inner template, to enable registering
* data and sub-templates.
*/
public TemplateProcessor getInnerTemplate(String innerTemplateKey) {
return this.innerTemplates.get(innerTemplateKey);
}
}