TemplateProcessor.java

package com.renomad.minum.templating;


import java.util.*;
import java.util.stream.Collectors;

/**
 * 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 {

    /**
     * The template is made up of a list of these, which are the static (unchanging)
     * parts, along with the dynamic parts the user fills in later.
     */
    private final List<TemplateSection> templateSections;

    /**
     * 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;

    /**
     * The String value given to us by the developer when creating this template
     */
    private final String originalText;

    /**
     * A general estimate of the resulting size of the output from the
     * template process, for one rendering of the template, to provide
     * quick information when building a buffer to contain the results
     * which is appropriately sized, which greatly improves performance
     */
    private final int estimatedSizeOfSingleTemplate;

    /**
     * Used when checking correctness - we check that the user is providing
     * key->value pairs that match the expected template keys.
     */
    private final Set<String> keysFoundInTemplate;


    /**
     * Instantiate a new object with a list of {@link TemplateSection}.
     */
    private TemplateProcessor(List<TemplateSection> templateSections, String originalText) {
        this.templateSections = templateSections;
        this.originalText = originalText;
        this.estimatedSizeOfSingleTemplate = (int) (originalText.length() * SIZE_ESTIMATE_MODIFIER);
        keysFoundInTemplate = templateSections.stream()
                .filter(x -> x.templateType.equals(TemplateType.DYNAMIC_TEXT))
                .map(x -> x.key)
                .collect(Collectors.toUnmodifiableSet());
    }

    /**
     * Given a map of key names -> value, render a template.
     */
    public String renderTemplate(Map<String, String> myMap) {
        return renderTemplate(List.of(myMap), "");
    }

    /**
     * Given a list, map of key names -> value, render one template
     * for each, joined by a newline.  Use {@link #renderTemplate(List, String)}
     * for control over the delimiter.
     */
    public String renderTemplate(List<Map<String, String>> data) {
        return renderTemplate(data, "\n");
    }

    /**
     * Render a list of maps.
     * <p>
     *     Similar to {@link #renderTemplate(Map)} but takes a list of maps instead
     *     of just one.  The "delimiter" argument is inserted between each rendered
     *     template.
     * </p>
     */
    public String renderTemplate(List<Map<String, String>> data, String delimiter) {
        correctnessCheck(data);

        // build an appropriately-sized buffer for output
        int capacity = estimatedSizeOfSingleTemplate * data.size();
        StringBuilder stringBuilder = new StringBuilder(capacity);

        for (int i = 0; i < data.size(); i++) {
            for (TemplateSection templateSection : templateSections) {
                templateSection.render(data.get(i), stringBuilder);
            }
            if (i != data.size() - 1) {
                stringBuilder.append(delimiter);
            }
        }
        return stringBuilder.toString();
    }


    /**
     * 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) {
        // 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 (justArrivedInside(template, charAtCursor, i)) {
                isInsideTemplateKeyLiteral = true;
                startOfKey = columnNumber;
                i += 1;
                builder = processSectionInside(builder, tSections);
            } else if (justArrivedOutside(template, charAtCursor, i, isInsideTemplateKeyLiteral)) {
                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(), 0, TemplateType.STATIC_TEXT));
                }
            }

            if (charAtCursor == '\n') {
                rowNumber += 1;
                columnNumber = 1;
            } else {
                columnNumber += 1;
            }

        }

        return new TemplateProcessor(tSections, template);
    }

    /**
     * Returns the raw template string provided at creation.
     */
    public String getOriginalText() {
        return this.originalText;
    }

    static StringBuilder processSectionInside(StringBuilder builder, List<TemplateSection> tSections) {
        if (!builder.isEmpty()) {
            tSections.add(new TemplateSection(null, builder.toString(), 0, TemplateType.STATIC_TEXT));
            builder = new StringBuilder();
        }
        return builder;
    }

    static StringBuilder processSectionOutside(StringBuilder builder, List<TemplateSection> tSections, int indent) {
        if (!builder.isEmpty()) {
            String trimmedKey = builder.toString().trim();
            tSections.add(new TemplateSection(trimmedKey, null, indent, TemplateType.DYNAMIC_TEXT));
            builder = new StringBuilder();
        }
        return builder;
    }

    /**
     * Just left a template key value.
     * <pre>
     *     hello {{ world }}
     *                ^
     *                +------Template key
     *
     * </pre>
     */
    static boolean justArrivedOutside(String template, char charAtCursor, int i, boolean isInsideTemplateKeyLiteral) {
        return charAtCursor == '}' && (i + 1) < template.length() && template.charAt(i + 1) == '}' && isInsideTemplateKeyLiteral;
    }

    /**
     * Just arrived inside a template key value.
     * <pre>
     *     hello {{ world }}
     *                ^
     *                +------Template key
     *
     * </pre>
     */
    static boolean justArrivedInside(String template, char charAtCursor, int i) {
        return charAtCursor == '{' && (i + 1) < template.length() && template.charAt(i + 1) == '{';
    }

    /**
     * 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(List<Map<String, String>> dataList) {
        HashSet<String> copyOfKeysInTemplate = new HashSet<>(keysFoundInTemplate);

        // check for inconsistencies between maps in the data list
        Set<String> keysInFirstMap = dataList.getFirst().keySet();

        for (Map<String, String> data : 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);
        }
    }

    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;
    }

}