| Author | DaveJarvis <email> |
|---|---|
| Date | 2020-06-07 20:33:21 GMT-0700 |
| Commit | fbb8e605af0e313fbf1445a717efc5ab116c6846 |
| Parent | 5d6dd09 |
| Delta | 308 lines added, 393 lines removed, 85-line decrease |
| +/* | ||
| + * Copyright 2020 White Magic Software, Ltd. | ||
| + * | ||
| + * All rights reserved. | ||
| + * | ||
| + * Redistribution and use in source and binary forms, with or without | ||
| + * modification, are permitted provided that the following conditions are met: | ||
| + * | ||
| + * o Redistributions of source code must retain the above copyright | ||
| + * notice, this list of conditions and the following disclaimer. | ||
| + * | ||
| + * o Redistributions in binary form must reproduce the above copyright | ||
| + * notice, this list of conditions and the following disclaimer in the | ||
| + * documentation and/or other materials provided with the distribution. | ||
| + * | ||
| + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | ||
| + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | ||
| + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | ||
| + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | ||
| + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | ||
| + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | ||
| + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | ||
| + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | ||
| + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||
| + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||
| + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
| + */ | ||
| +package com.scrivenvar.definition; | ||
| + | ||
| +import javafx.scene.control.TreeItem; | ||
| +import org.junit.jupiter.api.Test; | ||
| + | ||
| +import static java.lang.String.format; | ||
| +import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| + | ||
| +public class TreeItemInterpolatorTest { | ||
| + | ||
| + private final static String AUTHOR_FIRST = "FirstName"; | ||
| + private final static String AUTHOR_LAST = "LastName"; | ||
| + private final static String AUTHOR_ALL = "$root.name.first$ $root.name.last$"; | ||
| + | ||
| + /** | ||
| + * Test that a hierarchical relationship of {@link TreeItem} instances can | ||
| + * create a flat map with all string values containing key names interpolated. | ||
| + */ | ||
| + @Test | ||
| + public void test_Resolve_ReferencesInTree_InterpolatedMap() { | ||
| + final var root = new TreeItem<>( "root" ); | ||
| + final var name = new TreeItem<>( "name" ); | ||
| + final var first = new TreeItem<>( "first" ); | ||
| + final var authorFirst = new TreeItem<>( AUTHOR_FIRST ); | ||
| + final var last = new TreeItem<>( "last" ); | ||
| + final var authorLast = new TreeItem<>( AUTHOR_LAST ); | ||
| + final var full = new TreeItem<>( "full" ); | ||
| + final var expr = new TreeItem<>( AUTHOR_ALL ); | ||
| + | ||
| + root.getChildren().add( name ); | ||
| + name.getChildren().add( first ); | ||
| + name.getChildren().add( last ); | ||
| + name.getChildren().add( full ); | ||
| + | ||
| + first.getChildren().add( authorFirst ); | ||
| + last.getChildren().add( authorLast ); | ||
| + full.getChildren().add( expr ); | ||
| + | ||
| + final var map = TreeItemInterpolator.toMap( root ); | ||
| + | ||
| + var actualAuthor = map.get( "$root.name.full$" ); | ||
| + var expectedAuthor = AUTHOR_ALL; | ||
| + assertEquals( expectedAuthor, actualAuthor ); | ||
| + | ||
| + TreeItemInterpolator.interpolate( map ); | ||
| + actualAuthor = map.get( "$root.name.full$" ); | ||
| + | ||
| + expectedAuthor = format( "%s %s", AUTHOR_FIRST, AUTHOR_LAST ); | ||
| + assertEquals( expectedAuthor, actualAuthor ); | ||
| + } | ||
| +} | ||
| package com.scrivenvar.definition.yaml; | ||
| -import com.fasterxml.jackson.core.ObjectCodec; | ||
| -import com.fasterxml.jackson.core.io.IOContext; | ||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| -import com.fasterxml.jackson.databind.node.NullNode; | ||
| import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; | ||
| -import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; | ||
| import com.scrivenvar.Messages; | ||
| -import com.scrivenvar.decorators.VariableDecorator; | ||
| -import com.scrivenvar.decorators.YamlVariableDecorator; | ||
| import com.scrivenvar.definition.DocumentParser; | ||
| -import org.yaml.snakeyaml.DumperOptions; | ||
| -import java.io.IOException; | ||
| import java.io.InputStream; | ||
| -import java.io.Writer; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| -import java.util.HashMap; | ||
| -import java.util.Map; | ||
| -import java.util.Map.Entry; | ||
| -import java.util.regex.Matcher; | ||
| -import java.util.regex.Pattern; | ||
| -import static com.scrivenvar.Constants.DEFAULT_MAP_SIZE; | ||
| import static com.scrivenvar.Constants.STATUS_BAR_OK; | ||
| /** | ||
| - * <p> | ||
| - * This program loads a YAML document into memory, scans for variable | ||
| - * declarations, then substitutes any self-referential values back into the | ||
| - * document. Its output is the given YAML document without any variables. | ||
| - * Variables in the YAML document are denoted using a bracketed dollar symbol | ||
| - * syntax. For example: $field.name$. Some nomenclature to keep from going | ||
| - * squirrely, consider: | ||
| - * </p> | ||
| - * | ||
| - * <pre> | ||
| - * root: | ||
| - * node: | ||
| - * name: $field.name$ | ||
| - * field: | ||
| - * name: Alan Turing | ||
| - * </pre> | ||
| - * <p> | ||
| - * The various components of the given YAML are called: | ||
| - * | ||
| - * <ul> | ||
| - * <li><code>$field.name$</code> - delimited reference</li> | ||
| - * <li><code>field.name</code> - reference</li> | ||
| - * <li><code>name</code> - YAML field</li> | ||
| - * <li><code>Alan Turing</code> - (dereferenced) field value</li> | ||
| - * </ul> | ||
| + * Responsible for reading a YAML document into an object hierarchy. | ||
| * | ||
| * @author White Magic Software, Ltd. | ||
| */ | ||
| public class YamlParser implements DocumentParser<JsonNode> { | ||
| - | ||
| - private final static VariableDecorator VARIABLE_DECORATOR | ||
| - = new YamlVariableDecorator(); | ||
| - | ||
| - /** | ||
| - * Separates YAML variable nodes (e.g., the dots in | ||
| - * <code>$root.node.var$</code>). | ||
| - */ | ||
| - public static final String SEPARATOR = "."; | ||
| - | ||
| - /** | ||
| - * Should be JsonPointer.SEPARATOR, but Jackson YAML uses magic values. | ||
| - */ | ||
| - private final static char SEPARATOR_YAML = '/'; | ||
| - | ||
| - private final static int GROUP_DELIMITED = 1; | ||
| - private final static int GROUP_REFERENCE = 2; | ||
| - | ||
| - /** | ||
| - * Compiled regular expression for matching delimited references. | ||
| - */ | ||
| - private final static Pattern REGEX_PATTERN | ||
| - = Pattern.compile( YamlVariableDecorator.REGEX ); | ||
| - | ||
| - /** | ||
| - * Flat map of references to dereferenced field values. | ||
| - */ | ||
| - private final Map<String, String> mReferences = | ||
| - new HashMap<>( DEFAULT_MAP_SIZE ); | ||
| /** | ||
| assert path != null; | ||
| mDocumentRoot = parse( path ); | ||
| - interpolate( mDocumentRoot ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Parses the given path containing YAML data into an object hierarchy. | ||
| - * | ||
| - * @param path {@link Path} to the YAML resource to parse. | ||
| - * @return The parsed contents, or an empty object hierarchy. | ||
| - */ | ||
| - private JsonNode parse( final Path path ) { | ||
| - try( final InputStream in = Files.newInputStream( path ) ) { | ||
| - return parse( in ); | ||
| - } catch( final Exception e ) { | ||
| - setError( Messages.get( "yaml.error.open" ) ); | ||
| - | ||
| - // Ensure that a document root node exists by relying on the | ||
| - // default failure condition when processing. This is required | ||
| - // because the input stream could not be read. | ||
| - return new ObjectMapper().createObjectNode(); | ||
| - } | ||
| } | ||
| public JsonNode getDocumentRoot() { | ||
| return mDocumentRoot; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the given string with all the delimited references swapped with | ||
| - * their recursively resolved values. | ||
| - * | ||
| - * @param text The text to parse with zero or more delimited references to | ||
| - * replace. | ||
| - * @return The substituted value. | ||
| - */ | ||
| - public String substitute( String text ) { | ||
| - final Matcher matcher = patternMatch( text ); | ||
| - final Map<String, String> map = getReferences(); | ||
| - | ||
| - while( matcher.find() ) { | ||
| - final String key = matcher.group( GROUP_DELIMITED ); | ||
| - final String value = map.getOrDefault( key, key ); | ||
| - | ||
| - text = text.replace( key, value ); | ||
| - } | ||
| - | ||
| - return text; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns all the strings with their values resolved in a flat hierarchy. | ||
| - * This copies all the keys and resolved values into a new map. | ||
| - * | ||
| - * @return The new map created with all values having been resolved, | ||
| - * recursively. | ||
| - */ | ||
| - public Map<String, String> createResolvedMap() { | ||
| - final Map<String, String> map = new HashMap<>( DEFAULT_MAP_SIZE ); | ||
| - | ||
| - resolve( getDocumentRoot(), "", map ); | ||
| - | ||
| - return map; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Iterate over a given root node (at any level of the tree) and adapt each | ||
| - * leaf node. | ||
| - * | ||
| - * @param rootNode A JSON node (YAML node) to adapt. | ||
| - * @param map Container that associates definitions with values. | ||
| - */ | ||
| - private void resolve( | ||
| - final JsonNode rootNode, | ||
| - final String path, | ||
| - final Map<String, String> map ) { | ||
| - | ||
| - if( rootNode != null ) { | ||
| - rootNode.fields().forEachRemaining( | ||
| - ( Entry<String, JsonNode> leaf ) -> resolve( leaf, path, map ) | ||
| - ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Look up the value for a given node. | ||
| - * | ||
| - * @param root The node to resolve (contains a key to find). | ||
| - * @param path The path to the node. | ||
| - * @param map Flat map of existing key/value pairs. | ||
| - */ | ||
| - private void resolve( | ||
| - final Entry<String, JsonNode> root, | ||
| - final String path, | ||
| - final Map<String, String> map ) { | ||
| - final JsonNode leaf = root.getValue(); | ||
| - final String key = root.getKey(); | ||
| - | ||
| - if( leaf.isValueNode() ) { | ||
| - map.put( | ||
| - VARIABLE_DECORATOR.decorate( path + key ), | ||
| - substitute( | ||
| - leaf instanceof NullNode ? "" : leaf.asText() | ||
| - ) | ||
| - ); | ||
| - } | ||
| - else if( leaf.isObject() ) { | ||
| - resolve( leaf, path + key + SEPARATOR, map ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Reads the first document from the given stream of YAML data and returns a | ||
| - * corresponding object that represents the YAML hierarchy. The calling class | ||
| - * is responsible for closing the stream. Calling classes should use | ||
| - * <code>JsonNode.fields()</code> to walk through the YAML tree of fields. | ||
| - * | ||
| - * @param in The input stream containing YAML content. | ||
| - */ | ||
| - private JsonNode parse( final InputStream in ) throws IOException { | ||
| - setError( Messages.get( STATUS_BAR_OK ) ); | ||
| - | ||
| - final YAMLFactory factory = new ResolverYamlFactory(); | ||
| - final ObjectMapper mapper = new ObjectMapper( factory ); | ||
| - return mapper.readTree( in ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Iterate over a given root node (at any level of the tree) and process each | ||
| - * leaf node. | ||
| - * | ||
| - * @param root A node to process. | ||
| - */ | ||
| - private void interpolate( final JsonNode root ) { | ||
| - root.fields().forEachRemaining( this::interpolate ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Process the given field, which is a named node. This is where the | ||
| - * application does the up-front work of mapping references to their fully | ||
| - * recursively dereferenced values. | ||
| - * | ||
| - * @param field The named node. | ||
| - */ | ||
| - private void interpolate( final Entry<String, JsonNode> field ) { | ||
| - final JsonNode node = field.getValue(); | ||
| - | ||
| - if( node.isObject() ) { | ||
| - interpolate( node ); | ||
| - } | ||
| - else { | ||
| - final JsonNode fieldValue = field.getValue(); | ||
| - | ||
| - // Only basic data types can be parsed into variable values. For | ||
| - // node structures, YAML has a built-in mechanism. | ||
| - if( fieldValue.isValueNode() ) { | ||
| - try { | ||
| - resolve( fieldValue.asText() ); | ||
| - } catch( final StackOverflowError e ) { | ||
| - final String msg = Messages.get( | ||
| - "yaml.error.unresolvable", node.textValue(), fieldValue ); | ||
| - setError( msg ); | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Inserts the delimited references and field values into the cache. This will | ||
| - * overwrite existing references. | ||
| - * | ||
| - * @param fieldValue YAML field containing zero or more delimited references. | ||
| - * If it contains a delimited reference, the parameter is | ||
| - * modified with the | ||
| - * dereferenced value before it is returned. | ||
| - * @return fieldValue without delimited references. | ||
| - */ | ||
| - private String resolve( String fieldValue ) { | ||
| - final Matcher matcher = patternMatch( fieldValue ); | ||
| - | ||
| - while( matcher.find() ) { | ||
| - final String delimited = matcher.group( GROUP_DELIMITED ); | ||
| - final String reference = matcher.group( GROUP_REFERENCE ); | ||
| - final String dereference = resolve( lookup( reference ) ); | ||
| - | ||
| - fieldValue = fieldValue.replace( delimited, dereference ); | ||
| - | ||
| - // This will perform some superfluous calls by overwriting existing | ||
| - // items in the delimited reference map. | ||
| - put( delimited, dereference ); | ||
| - } | ||
| - | ||
| - return fieldValue; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Inserts a key/value pair into the references map. The map retains | ||
| - * references and dereferenced values found in the YAML. If the reference | ||
| - * already exists, this will overwrite with a new value. | ||
| - * | ||
| - * @param delimited The variable name. | ||
| - * @param dereferenced The resolved value. | ||
| - */ | ||
| - private void put( final String delimited, final String dereferenced ) { | ||
| - if( dereferenced.isEmpty() ) { | ||
| - missing( delimited ); | ||
| - } | ||
| - else { | ||
| - getReferences().put( delimited, dereferenced ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Called when a delimited reference is dereferenced to an empty string. This | ||
| - * should produce a warning for the user. | ||
| - * | ||
| - * @param delimited Delimited reference with no derived value. | ||
| - */ | ||
| - private void missing( final String delimited ) { | ||
| - setError( Messages.get( "yaml.error.missing", delimited ) ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns a REGEX_PATTERN matcher for the given text. | ||
| - * | ||
| - * @param text The text that contains zero or more instances of a | ||
| - * REGEX_PATTERN that can be found using the regular expression. | ||
| - */ | ||
| - private Matcher patternMatch( final String text ) { | ||
| - return REGEX_PATTERN.matcher( text ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Finds the YAML value for a reference. | ||
| - * | ||
| - * @param reference References a value in the YAML document. | ||
| - * @return The dereferenced value. | ||
| - */ | ||
| - private String lookup( final String reference ) { | ||
| - return getDocumentRoot().at( asPath( reference ) ).asText(); | ||
| } | ||
| /** | ||
| - * Converts a reference (not delimited) to a path that can be used to find a | ||
| - * value that should exist inside the YAML document. | ||
| + * Parses the given path containing YAML data into an object hierarchy. | ||
| * | ||
| - * @param reference The reference to convert to a YAML document path. | ||
| - * @return The reference with a leading slash and its separator characters | ||
| - * converted to slashes. | ||
| - */ | ||
| - private String asPath( final String reference ) { | ||
| - return SEPARATOR_YAML + reference.replace( | ||
| - getDelimitedSeparator(), SEPARATOR_YAML ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * @return The list of references mapped to dereferenced values. | ||
| + * @param path {@link Path} to the YAML resource to parse. | ||
| + * @return The parsed contents, or an empty object hierarchy. | ||
| */ | ||
| - private Map<String, String> getReferences() { | ||
| - return mReferences; | ||
| - } | ||
| - | ||
| - private final class ResolverYamlFactory extends YAMLFactory { | ||
| - | ||
| - private static final long serialVersionUID = 1L; | ||
| - | ||
| - @Override | ||
| - protected YAMLGenerator _createGenerator( | ||
| - final Writer out, final IOContext ctxt ) throws IOException { | ||
| - | ||
| - return new ResolverYamlGenerator( | ||
| - ctxt, _generatorFeatures, _yamlGeneratorFeatures, _objectCodec, | ||
| - out, _version ); | ||
| - } | ||
| - } | ||
| - | ||
| - private class ResolverYamlGenerator extends YAMLGenerator { | ||
| + private JsonNode parse( final Path path ) { | ||
| + try( final InputStream in = Files.newInputStream( path ) ) { | ||
| + setError( Messages.get( STATUS_BAR_OK ) ); | ||
| - public ResolverYamlGenerator( | ||
| - final IOContext ctxt, | ||
| - final int jsonFeatures, | ||
| - final int yamlFeatures, | ||
| - final ObjectCodec codec, | ||
| - final Writer out, | ||
| - final DumperOptions.Version version ) throws IOException { | ||
| - super( ctxt, jsonFeatures, yamlFeatures, codec, out, version ); | ||
| - } | ||
| + return new ObjectMapper( new YAMLFactory() ).readTree( in ); | ||
| + } catch( final Exception e ) { | ||
| + setError( Messages.get( "yaml.error.open" ) ); | ||
| - @Override | ||
| - public void writeString( final String text ) throws IOException { | ||
| - super.writeString( substitute( text ) ); | ||
| + // Ensure that a document root node exists by relying on the | ||
| + // default failure condition when processing. This is required | ||
| + // because the input stream could not be read. | ||
| + return new ObjectMapper().createObjectNode(); | ||
| } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the character used to separate YAML paths within delimited | ||
| - * references. This will return only the first character of the command line | ||
| - * parameter, if the default is overridden. | ||
| - * | ||
| - * @return A period by default. | ||
| - */ | ||
| - private char getDelimitedSeparator() { | ||
| - return SEPARATOR.charAt( 0 ); | ||
| } | ||
| */ | ||
| public String getError() { | ||
| - return mError == null ? "" : mError; | ||
| + return mError; | ||
| } | ||
| } | ||
| import com.fasterxml.jackson.databind.node.ObjectNode; | ||
| import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; | ||
| +import com.scrivenvar.definition.RootTreeItem; | ||
| import com.scrivenvar.definition.TreeAdapter; | ||
| import com.scrivenvar.definition.VariableTreeItem; | ||
| import javafx.scene.control.TreeItem; | ||
| +import javafx.scene.control.TreeView; | ||
| import java.io.IOException; | ||
| public TreeItem<String> adapt( final String root ) { | ||
| final JsonNode rootNode = getYamlParser().getDocumentRoot(); | ||
| - final TreeItem<String> rootItem = createTreeItem( root ); | ||
| + final TreeItem<String> rootItem = createRootTreeItem( root ); | ||
| rootItem.setExpanded( true ); | ||
| /** | ||
| - * Creates a new tree item that can be added to the tree view. | ||
| + * Creates a new {@link TreeItem} that can be added to the {@link TreeView}. | ||
| * | ||
| * @param value The node's value. | ||
| - * @return A new tree item node, never null. | ||
| + * @return A new {@link TreeItem}, never {@code null}. | ||
| */ | ||
| private TreeItem<String> createTreeItem( final String value ) { | ||
| return new VariableTreeItem<>( value ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a new {@link TreeItem} that is intended to be the root-level item | ||
| + * added to the {@link TreeView}. This allows the root item to be | ||
| + * distinguished from the other items so that reference keys do not include | ||
| + * "Definition" as part of their name. | ||
| + * | ||
| + * @param value The node's value. | ||
| + * @return A new {@link TreeItem}, never {@code null}. | ||
| + */ | ||
| + private TreeItem<String> createRootTreeItem( final String value ) { | ||
| + return new RootTreeItem<>( value ); | ||
| } | ||
| +/* | ||
| + * Copyright 2020 White Magic Software, Ltd. | ||
| + * | ||
| + * All rights reserved. | ||
| + * | ||
| + * Redistribution and use in source and binary forms, with or without | ||
| + * modification, are permitted provided that the following conditions are met: | ||
| + * | ||
| + * o Redistributions of source code must retain the above copyright | ||
| + * notice, this list of conditions and the following disclaimer. | ||
| + * | ||
| + * o Redistributions in binary form must reproduce the above copyright | ||
| + * notice, this list of conditions and the following disclaimer in the | ||
| + * documentation and/or other materials provided with the distribution. | ||
| + * | ||
| + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | ||
| + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | ||
| + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | ||
| + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | ||
| + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | ||
| + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | ||
| + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | ||
| + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | ||
| + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||
| + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||
| + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
| + */ | ||
| +package com.scrivenvar.definition; | ||
| + | ||
| +import com.fasterxml.jackson.databind.JsonNode; | ||
| +import com.scrivenvar.decorators.YamlVariableDecorator; | ||
| +import com.scrivenvar.preview.HTMLPreviewPane; | ||
| +import javafx.scene.control.TreeItem; | ||
| +import javafx.scene.control.TreeView; | ||
| + | ||
| +import java.util.HashMap; | ||
| +import java.util.Iterator; | ||
| +import java.util.Map; | ||
| +import java.util.Stack; | ||
| +import java.util.regex.Matcher; | ||
| + | ||
| +import static com.scrivenvar.Constants.DEFAULT_MAP_SIZE; | ||
| +import static com.scrivenvar.decorators.YamlVariableDecorator.REGEX_PATTERN; | ||
| + | ||
| +/** | ||
| + * Given a {@link TreeItem}, this will generate a flat map with all the | ||
| + * values in the tree recursively interpolated. The application integrates | ||
| + * definition files as follows: | ||
| + * <ol> | ||
| + * <li>Load YAML file into {@link JsonNode} hierarchy.</li> | ||
| + * <li>Convert JsonNode to a {@link TreeItem} hierarchy.</li> | ||
| + * <li>Interpolate {@link TreeItem} hierarchy as a flat map.</li> | ||
| + * <li>Substitute flat map variables into document as required.</li> | ||
| + * </ol> | ||
| + * | ||
| + * <p> | ||
| + * This class is responsible for producing the interpolated flat map. This | ||
| + * allows dynamic edits of the {@link TreeView} to be displayed in the | ||
| + * {@link HTMLPreviewPane} without having to reload the definition file. | ||
| + * Reloading the definition file would work, but has a number of drawbacks. | ||
| + * </p> | ||
| + * | ||
| + * @author White Magic Software, Ltd. | ||
| + */ | ||
| +public class TreeItemInterpolator { | ||
| + /** | ||
| + * Separates YAML variable nodes (e.g., the dots in {@code $root.node.var$]). | ||
| + */ | ||
| + public static final String SEPARATOR = "."; | ||
| + | ||
| + private final static int GROUP_DELIMITED = 1; | ||
| + | ||
| + /** | ||
| + * Default buffer length for keys ({@link StringBuilder} has 16 character | ||
| + * buffer) that should be large enough for most keys to avoid reallocating | ||
| + * memory to increase the {@link StringBuilder}'s buffer. | ||
| + */ | ||
| + public static final int DEFAULT_KEY_LENGTH = 64; | ||
| + | ||
| + /** | ||
| + * In-order traversal of a {@link TreeItem} hierarchy, exposing each item | ||
| + * as a consecutive list. | ||
| + */ | ||
| + private static final class TreeIterator | ||
| + implements Iterator<TreeItem<String>> { | ||
| + private final Stack<TreeItem<String>> mStack = new Stack<>(); | ||
| + | ||
| + public TreeIterator( final TreeItem<String> root ) { | ||
| + if( root != null ) { | ||
| + mStack.push( root ); | ||
| + } | ||
| + } | ||
| + | ||
| + @Override | ||
| + public boolean hasNext() { | ||
| + return !mStack.isEmpty(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public TreeItem<String> next() { | ||
| + final TreeItem<String> next = mStack.pop(); | ||
| + next.getChildren().forEach( mStack::push ); | ||
| + | ||
| + return next; | ||
| + } | ||
| + } | ||
| + | ||
| + private TreeItemInterpolator() { | ||
| + } | ||
| + | ||
| + /** | ||
| + * Iterate over a given root node (at any level of the tree) and process each | ||
| + * leaf node into a flat map. Values must be interpolated separately. | ||
| + */ | ||
| + public static Map<String, String> toMap( final TreeItem<String> root ) { | ||
| + final Map<String, String> map = new HashMap<>( DEFAULT_MAP_SIZE ); | ||
| + final TreeIterator iterator = new TreeIterator( root ); | ||
| + | ||
| + iterator.forEachRemaining( item -> { | ||
| + if( item.isLeaf() ) { | ||
| + map.put( toPath( item.getParent() ), item.getValue() ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + return map; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Performs string interpolation on the values in the given map. This will | ||
| + * change any value in the map that contains a variable that matches | ||
| + * {@link YamlVariableDecorator#REGEX_PATTERN}. | ||
| + * | ||
| + * @param map Contains values that represent references to keys. | ||
| + */ | ||
| + public static void interpolate( final Map<String, String> map ) { | ||
| + map.replaceAll( ( k, v ) -> resolve( map, v ) ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Given a value with zero or more key references, this will resolve all | ||
| + * the values, recursively. If a key cannot be dereferenced, the value will | ||
| + * contain the key name. | ||
| + * | ||
| + * @param map Map to search for keys when resolving key references. | ||
| + * @param value Value containing zero or more key references | ||
| + * @return The given value with all embedded key references interpolated. | ||
| + */ | ||
| + private static String resolve( | ||
| + final Map<String, String> map, String value ) { | ||
| + final Matcher matcher = REGEX_PATTERN.matcher( value ); | ||
| + | ||
| + while( matcher.find() ) { | ||
| + final String keyName = matcher.group( GROUP_DELIMITED ); | ||
| + | ||
| + final String keyValue = resolve( | ||
| + map, map.getOrDefault( keyName, keyName ) | ||
| + ); | ||
| + | ||
| + value = value.replace( keyName, keyValue ); | ||
| + } | ||
| + | ||
| + return value; | ||
| + } | ||
| + | ||
| + /** | ||
| + * For a given node, this will ascend the tree to generate a key name | ||
| + * that is associated with the leaf node's value. | ||
| + * | ||
| + * @param node Ascendants represent the key to this node's value. | ||
| + * @param <T> Data type that the {@link TreeItem} contains. | ||
| + * @return The string representation of the node's unique key. | ||
| + */ | ||
| + public static <T> String toPath( TreeItem<T> node ) { | ||
| + assert node != null; | ||
| + | ||
| + final StringBuilder key = new StringBuilder( DEFAULT_KEY_LENGTH ); | ||
| + final Stack<TreeItem<T>> stack = new Stack<>(); | ||
| + | ||
| + while( node != null && !(node instanceof RootTreeItem) ) { | ||
| + stack.push( node ); | ||
| + node = node.getParent(); | ||
| + } | ||
| + | ||
| + // Gets set at end of first iteration (to avoid an if condition). | ||
| + String separator = ""; | ||
| + | ||
| + while( !stack.empty() ) { | ||
| + final T subkey = stack.pop().getValue(); | ||
| + key.append( separator ); | ||
| + key.append( subkey ); | ||
| + separator = SEPARATOR; | ||
| + } | ||
| + | ||
| + return YamlVariableDecorator.entoken( key.toString() ); | ||
| + } | ||
| +} | ||
| import static com.scrivenvar.definition.FindMode.CONTAINS; | ||
| import static com.scrivenvar.definition.FindMode.STARTS_WITH; | ||
| -import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR; | ||
| import static java.text.Normalizer.Form.NFD; | ||
| /** | ||
| * Provides behaviour afforded to variable names and their corresponding value. | ||
| * | ||
| * @param <T> The type of TreeItem (usually String). | ||
| * @author White Magic Software, Ltd. | ||
| */ | ||
| public class VariableTreeItem<T> extends TreeItem<T> { | ||
| - public static final int DEFAULT_MAX_VAR_LENGTH = 64; | ||
| /** | ||
| */ | ||
| public String toPath() { | ||
| - final Stack<TreeItem<T>> stack = new Stack<>(); | ||
| - TreeItem<T> node = this; | ||
| - | ||
| - while( node.getParent() != null ) { | ||
| - stack.push( node ); | ||
| - node = node.getParent(); | ||
| - } | ||
| - | ||
| - final StringBuilder sb = new StringBuilder( DEFAULT_MAX_VAR_LENGTH ); | ||
| - | ||
| - while( !stack.isEmpty() ) { | ||
| - node = stack.pop(); | ||
| - | ||
| - if( !node.isLeaf() ) { | ||
| - sb.append( node.getValue() ); | ||
| - | ||
| - // This will add a superfluous separator, but instead of peeking at | ||
| - // the stack all the time, the last separator will be removed outside | ||
| - // the loop (one operation executed once). | ||
| - sb.append( SEPARATOR ); | ||
| - } | ||
| - } | ||
| - | ||
| - // Remove the trailing SEPARATOR. | ||
| - if( sb.length() > 0 ) { | ||
| - sb.setLength( sb.length() - 1 ); | ||
| - } | ||
| - | ||
| - return sb.toString(); | ||
| + return TreeItemInterpolator.toPath( getParent() ); | ||
| } | ||
| } | ||