org.apache.brooklyn.util.yaml.Yamls.java Source code

Java tutorial

Introduction

Here is the source code for org.apache.brooklyn.util.yaml.Yamls.java

Source

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.brooklyn.util.yaml;

import java.io.Reader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.atomic.AtomicBoolean;

import javax.annotation.Nullable;

import org.apache.brooklyn.util.collections.Jsonya;
import org.apache.brooklyn.util.collections.MutableList;
import org.apache.brooklyn.util.exceptions.Exceptions;
import org.apache.brooklyn.util.exceptions.UserFacingException;
import org.apache.brooklyn.util.text.Strings;
import org.apache.brooklyn.util.yaml.Yamls;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.error.Mark;
import org.yaml.snakeyaml.nodes.MappingNode;
import org.yaml.snakeyaml.nodes.Node;
import org.yaml.snakeyaml.nodes.NodeId;
import org.yaml.snakeyaml.nodes.NodeTuple;
import org.yaml.snakeyaml.nodes.ScalarNode;
import org.yaml.snakeyaml.nodes.SequenceNode;

import com.google.common.annotations.Beta;
import com.google.common.collect.Iterables;

public class Yamls {

    private static final Logger log = LoggerFactory.getLogger(Yamls.class);

    /** returns the given (yaml-parsed) object as the given yaml type.
     * <p>
     * if the object is an iterable or iterator this method will fully expand it as a list. 
     * if the requested type is not an iterable or iterator, and the list contains a single item, this will take that single item.
     * <p>
     * in other cases this method simply does a type-check and cast (no other type coercion).
     * <p>
     * @throws IllegalArgumentException if the input is an iterable not containing a single element,
     *   and the cast is requested to a non-iterable type 
     * @throws ClassCastException if cannot be casted */
    @SuppressWarnings({ "unchecked", "rawtypes" })
    public static <T> T getAs(Object x, Class<T> type) {
        if (x == null)
            return null;
        if (x instanceof Iterable || x instanceof Iterator) {
            List result = new ArrayList();
            Iterator xi;
            if (Iterator.class.isAssignableFrom(x.getClass())) {
                xi = (Iterator) x;
            } else {
                xi = ((Iterable) x).iterator();
            }
            while (xi.hasNext()) {
                result.add(xi.next());
            }
            if (type.isAssignableFrom(List.class))
                return (T) result;
            if (type.isAssignableFrom(Iterator.class))
                return (T) result.iterator();
            x = Iterables.getOnlyElement(result);
        }
        if (type.isInstance(x))
            return (T) x;
        throw new ClassCastException("Cannot convert " + x + " (" + x.getClass() + ") to " + type);
    }

    /**
     * Parses the given yaml, and walks the given path to return the referenced object.
     * 
     * @see #getAt(Object, List)
     */
    @Beta
    public static Object getAt(String yaml, List<String> path) {
        Iterable<Object> result = new org.yaml.snakeyaml.Yaml().loadAll(yaml);
        Object current = result.iterator().next();
        return getAtPreParsed(current, path);
    }

    /** 
     * For pre-parsed yaml, walks the maps/lists to return the given sub-item.
     * In the given path:
     * <ul>
     *   <li>A vanilla string is assumed to be a key into a map.
     *   <li>A string in the form like "[0]" is assumed to be an index into a list
     * </ul>
     * 
     * Also see {@link Jsonya}, such as {@code Jsonya.of(current).at(path).get()}.
     * 
     * @return The object at the given path, or {@code null} if that path does not exist.
     */
    @Beta
    @SuppressWarnings("unchecked")
    public static Object getAtPreParsed(Object current, List<String> path) {
        for (String pathPart : path) {
            if (pathPart.startsWith("[") && pathPart.endsWith("]")) {
                String index = pathPart.substring(1, pathPart.length() - 1);
                try {
                    current = Iterables.get((Iterable<?>) current, Integer.parseInt(index));
                } catch (NumberFormatException e) {
                    throw new IllegalArgumentException("Invalid index '" + index + "', in path " + path);
                } catch (IndexOutOfBoundsException e) {
                    throw new IllegalArgumentException("Invalid index '" + index + "', in path " + path);
                }
            } else {
                current = ((Map<String, ?>) current).get(pathPart);
            }
            if (current == null)
                return null;
        }
        return current;
    }

    @SuppressWarnings("rawtypes")
    public static void dump(int depth, Object r) {
        if (r instanceof Iterable) {
            for (Object ri : ((Iterable) r))
                dump(depth + 1, ri);
        } else if (r instanceof Map) {
            for (Object re : ((Map) r).entrySet()) {
                for (int i = 0; i < depth; i++)
                    System.out.print(" ");
                System.out.println(((Entry) re).getKey() + ":");
                dump(depth + 1, ((Entry) re).getValue());
            }
        } else {
            for (int i = 0; i < depth; i++)
                System.out.print(" ");
            if (r == null)
                System.out.println("<null>");
            else
                System.out.println("<" + r.getClass().getSimpleName() + ">" + " " + r);
        }
    }

    /** simplifies new Yaml().loadAll, and converts to list to prevent single-use iterable bug in yaml */
    @SuppressWarnings("unchecked")
    public static Iterable<Object> parseAll(String yaml) {
        Iterable<Object> result = new org.yaml.snakeyaml.Yaml().loadAll(yaml);
        return (List<Object>) getAs(result, List.class);
    }

    /** as {@link #parseAll(String)} */
    @SuppressWarnings("unchecked")
    public static Iterable<Object> parseAll(Reader yaml) {
        Iterable<Object> result = new org.yaml.snakeyaml.Yaml().loadAll(yaml);
        return (List<Object>) getAs(result, List.class);
    }

    public static Object removeMultinameAttribute(Map<String, Object> obj, String... equivalentNames) {
        Object result = null;
        for (String name : equivalentNames) {
            Object candidate = obj.remove(name);
            if (candidate != null) {
                if (result == null)
                    result = candidate;
                else if (!result.equals(candidate)) {
                    log.warn("Different values for attributes " + Arrays.toString(equivalentNames) + "; "
                            + "preferring '" + result + "' to '" + candidate + "'");
                }
            }
        }
        return result;
    }

    public static Object getMultinameAttribute(Map<String, Object> obj, String... equivalentNames) {
        Object result = null;
        for (String name : equivalentNames) {
            Object candidate = obj.get(name);
            if (candidate != null) {
                if (result == null)
                    result = candidate;
                else if (!result.equals(candidate)) {
                    log.warn("Different values for attributes " + Arrays.toString(equivalentNames) + "; "
                            + "preferring '" + result + "' to '" + candidate + "'");
                }
            }
        }
        return result;
    }

    @Beta
    public static class YamlExtract {
        String yaml;
        NodeTuple focusTuple;
        Node prev, key, focus, next;
        Exception error;
        boolean includeKey = false, includePrecedingComments = true, includeOriginalIndentation = false;

        private int indexStart(Node node, boolean defaultIsYamlEnd) {
            if (node == null)
                return defaultIsYamlEnd ? yaml.length() : 0;
            return index(node.getStartMark());
        }

        private int indexEnd(Node node, boolean defaultIsYamlEnd) {
            if (!found() || node == null)
                return defaultIsYamlEnd ? yaml.length() : 0;
            return index(node.getEndMark());
        }

        private int index(Mark mark) {
            try {
                return mark.getIndex();
            } catch (NoSuchMethodError e) {
                try {
                    getClass().getClassLoader().loadClass("org.testng.TestNG");
                } catch (ClassNotFoundException e1) {
                    // not using TestNG
                    Exceptions.propagateIfFatal(e1);
                    throw e;
                }
                if (!LOGGED_TESTNG_WARNING.getAndSet(true)) {
                    log.warn("Detected TestNG/SnakeYAML version incompatibilities: "
                            + "some YAML source reconstruction will be unavailable. "
                            + "This can happen with TestNG plugins which force an older version of SnakeYAML "
                            + "which does not support Mark.getIndex. " + "It should not occur from maven CLI runs. "
                            + "(Subsequent occurrences will be silently dropped, and source code reconstructed from YAML.)");
                }
                // using TestNG
                throw new KnownClassVersionException(e);
            }
        }

        static AtomicBoolean LOGGED_TESTNG_WARNING = new AtomicBoolean();

        static class KnownClassVersionException extends IllegalStateException {
            private static final long serialVersionUID = -1620847775786753301L;

            public KnownClassVersionException(Throwable e) {
                super("Class version error. This can happen if using a TestNG plugin in your IDE "
                        + "which is an older version, dragging in an older version of SnakeYAML which does not support Mark.getIndex.",
                        e);
            }
        }

        public int getEndOfPrevious() {
            return indexEnd(prev, false);
        }

        @Nullable
        public Node getKey() {
            return key;
        }

        public Node getResult() {
            return focus;
        }

        public int getStartOfThis() {
            if (includeKey && focusTuple != null)
                return indexStart(focusTuple.getKeyNode(), false);
            return indexStart(focus, false);
        }

        private int getStartColumnOfThis() {
            if (includeKey && focusTuple != null)
                return focusTuple.getKeyNode().getStartMark().getColumn();
            return focus.getStartMark().getColumn();
        }

        public int getEndOfThis() {
            return getEndOfThis(false);
        }

        private int getEndOfThis(boolean goToEndIfNoNext) {
            if (next == null && goToEndIfNoNext)
                return yaml.length();
            return indexEnd(focus, false);
        }

        public int getStartOfNext() {
            return indexStart(next, true);
        }

        private static int initialWhitespaceLength(String x) {
            int i = 0;
            while (i < x.length() && x.charAt(i) == ' ')
                i++;
            return i;
        }

        public String getFullYamlTextOriginal() {
            return yaml;
        }

        /** Returns the original YAML with the found item replaced by the given replacement YAML.
         * @param replacement YAML to put in for the found item;
         * this YAML typically should not have any special indentation -- if required when replacing it will be inserted.
         * <p>
         * if replacing an inline map entry, the supplied entry must follow the structure being replaced;
         * for example, if replacing the value in <code>key: value</code> with a map,
         * supplying a replacement <code>subkey: value</code> would result in invalid yaml;
         * the replacement must be supplied with a newline, either before the subkey or after.
         * (if unsure we believe it is always valid to include an initial newline or comment with newline.)
         */
        public String getFullYamlTextWithExtractReplaced(String replacement) {
            if (!found())
                throw new IllegalStateException("Cannot perform replacement when item was not matched.");
            String result = yaml.substring(0, getStartOfThis());

            String[] newLines = replacement.split("\n");
            for (int i = 1; i < newLines.length; i++)
                newLines[i] = Strings.makePaddedString("", getStartColumnOfThis(), "", " ") + newLines[i];
            result += Strings.lines(newLines);
            if (replacement.endsWith("\n"))
                result += "\n";

            int end = getEndOfThis();
            result += yaml.substring(end);

            return result;
        }

        /** Specifies whether the key should be included in the found text, 
         * when calling {@link #getMatchedYamlText()} or {@link #getFullYamlTextWithExtractReplaced(String)},
         * if the found item is a map entry.
         * Defaults to false.
         * @return this object, for use in a fluent constructions
         */
        public YamlExtract withKeyIncluded(boolean includeKey) {
            this.includeKey = includeKey;
            return this;
        }

        /** Specifies whether comments preceding the found item should be included, 
         * when calling {@link #getMatchedYamlText()} or {@link #getFullYamlTextWithExtractReplaced(String)}.
         * This will not include comments which are indented further than the item,
         * as those will typically be aligned with the previous item
         * (whereas comments whose indentation is the same or less than the found item
         * will typically be aligned with this item).
         * Defaults to true.
         * @return this object, for use in a fluent constructions
         */
        public YamlExtract withPrecedingCommentsIncluded(boolean includePrecedingComments) {
            this.includePrecedingComments = includePrecedingComments;
            return this;
        }

        /** Specifies whether the original indentation should be preserved
         * (and in the case of the first line, whether whitespace should be inserted so its start column is preserved), 
         * when calling {@link #getMatchedYamlText()}.
         * Defaults to false, the returned text will be outdented as far as possible.
         * @return this object, for use in a fluent constructions
         */
        public YamlExtract withOriginalIndentation(boolean includeOriginalIndentation) {
            this.includeOriginalIndentation = includeOriginalIndentation;
            return this;
        }

        @Beta
        public String getMatchedYamlTextOrWarn() {
            try {
                return getMatchedYamlText();
            } catch (Exception e) {
                Exceptions.propagateIfFatal(e);
                if (e instanceof KnownClassVersionException) {
                    log.debug("Known class version exception; no yaml text being matched for " + this + ": " + e);
                } else {
                    if (e instanceof UserFacingException) {
                        log.warn("Unable to match yaml text in " + this + ": " + e.getMessage());
                    } else {
                        log.warn("Unable to match yaml text in " + this + ": " + e, e);
                    }
                }
                return null;
            }
        }

        @Beta
        public String getMatchedYamlText() {
            if (!found())
                return null;

            String[] body = yaml.substring(getStartOfThis(), getEndOfThis(true)).split("\n", -1);

            int firstLineIndentationOfFirstThing;
            if (focusTuple != null) {
                firstLineIndentationOfFirstThing = focusTuple.getKeyNode().getStartMark().getColumn();
            } else {
                firstLineIndentationOfFirstThing = focus.getStartMark().getColumn();
            }
            int firstLineIndentationToAdd;
            if (focusTuple != null && (includeKey || body.length == 1)) {
                firstLineIndentationToAdd = focusTuple.getKeyNode().getStartMark().getColumn();
            } else {
                firstLineIndentationToAdd = focus.getStartMark().getColumn();
            }

            String firstLineIndentationToAddS = Strings.makePaddedString("", firstLineIndentationToAdd, "", " ");
            String subsequentLineIndentationToRemoveS = firstLineIndentationToAddS;

            /* complexities of indentation:
                
            x: a
             bc
                 
            should become
                
            a
             bc
                
            whereas
                
            - a: 0
              b: 1
                  
            selecting 0 should give
                
            a: 0
            b: 1
                
             */
            List<String> result = MutableList.of();
            if (includePrecedingComments) {
                if (getEndOfPrevious() > getStartOfThis()) {
                    throw new UserFacingException("YAML not in expected format; when scanning, previous end "
                            + getEndOfPrevious() + " exceeds this start " + getStartOfThis());
                }
                String[] preceding = yaml.substring(getEndOfPrevious(), getStartOfThis()).split("\n");
                // suppress comments which are on the same line as the previous item or indented more than firstLineIndentation,
                // ensuring appropriate whitespace is added to preceding[0] if it starts mid-line
                if (preceding.length > 0 && prev != null) {
                    preceding[0] = Strings.makePaddedString("", prev.getEndMark().getColumn(), "", " ")
                            + preceding[0];
                }
                for (String p : preceding) {
                    int w = initialWhitespaceLength(p);
                    p = p.substring(w);
                    if (p.startsWith("#")) {
                        // only add if the hash is not indented further than the first line
                        if (w <= firstLineIndentationOfFirstThing) {
                            if (includeOriginalIndentation)
                                p = firstLineIndentationToAddS + p;
                            result.add(p);
                        }
                    }
                }
            }

            boolean doneFirst = false;
            for (String p : body) {
                if (!doneFirst) {
                    if (includeOriginalIndentation) {
                        // have to insert the right amount of spacing
                        p = firstLineIndentationToAddS + p;
                    }
                    result.add(p);
                    doneFirst = true;
                } else {
                    if (includeOriginalIndentation) {
                        result.add(p);
                    } else {
                        result.add(Strings.removeFromStart(p, subsequentLineIndentationToRemoveS));
                    }
                }
            }
            return Strings.join(result, "\n");
        }

        boolean found() {
            return focus != null;
        }

        public Exception getError() {
            return error;
        }

        @Override
        public String toString() {
            return "Extract[" + focus + ";prev=" + prev + ";key=" + key + ";next=" + next + "]";
        }
    }

    private static void findTextOfYamlAtPath(YamlExtract result, int pathIndex, Object... path) {
        if (pathIndex >= path.length) {
            // we're done
            return;
        }

        Object pathItem = path[pathIndex];
        Node node = result.focus;

        if (node.getNodeId() == NodeId.mapping && pathItem instanceof String) {
            // find key
            Iterator<NodeTuple> ti = ((MappingNode) node).getValue().iterator();
            while (ti.hasNext()) {
                NodeTuple t = ti.next();
                Node key = t.getKeyNode();
                if (key.getNodeId() == NodeId.scalar) {
                    if (pathItem.equals(((ScalarNode) key).getValue())) {
                        result.key = key;
                        result.focus = t.getValueNode();
                        if (pathIndex + 1 < path.length) {
                            // there are more path items, so the key here is a previous node
                            result.prev = key;
                        } else {
                            result.focusTuple = t;
                        }
                        findTextOfYamlAtPath(result, pathIndex + 1, path);
                        if (result.next == null) {
                            if (ti.hasNext())
                                result.next = ti.next().getKeyNode();
                        }
                        return;
                    } else {
                        result.prev = t.getValueNode();
                    }
                } else {
                    throw new IllegalStateException("Key " + key + " is not a scalar, searching for " + pathItem
                            + " at depth " + pathIndex + " of " + Arrays.asList(path));
                }
            }
            throw new IllegalStateException("Did not find " + pathItem + " in " + node + " at depth " + pathIndex
                    + " of " + Arrays.asList(path));

        } else if (node.getNodeId() == NodeId.sequence && pathItem instanceof Number) {
            // find list item
            List<Node> nl = ((SequenceNode) node).getValue();
            int i = ((Number) pathItem).intValue();
            if (i >= nl.size())
                throw new IllegalStateException("Index " + i + " is out of bounds in " + node + ", searching for "
                        + pathItem + " at depth " + pathIndex + " of " + Arrays.asList(path));
            if (i > 0)
                result.prev = nl.get(i - 1);
            result.key = null;
            result.focus = nl.get(i);
            findTextOfYamlAtPath(result, pathIndex + 1, path);
            if (result.next == null) {
                if (nl.size() > i + 1)
                    result.next = nl.get(i + 1);
            }
            return;

        } else {
            throw new IllegalStateException("Node " + node + " does not match selector " + pathItem + " at depth "
                    + pathIndex + " of " + Arrays.asList(path));
        }

        // unreachable
    }

    /** Given a path, where each segment consists of a string (key) or number (element in list),
     * this will find the YAML text for that element
     * <p>
     * If not found this will return a {@link YamlExtract} 
     * where {@link YamlExtract#isMatch()} is false and {@link YamlExtract#getError()} is set. */
    public static YamlExtract getTextOfYamlAtPath(String yaml, Object... path) {
        YamlExtract result = new YamlExtract();
        if (yaml == null)
            return result;
        try {
            int pathIndex = 0;
            result.yaml = yaml;
            result.focus = new Yaml().compose(new StringReader(yaml));

            findTextOfYamlAtPath(result, pathIndex, path);
            return result;
        } catch (NoSuchMethodError e) {
            throw new IllegalStateException(
                    "Class version error. This can happen if using a TestNG plugin in your IDE "
                            + "which is an older version, dragging in an older version of SnakeYAML which does not support Mark.getIndex.",
                    e);
        } catch (Exception e) {
            Exceptions.propagateIfFatal(e);
            log.debug("Unable to find element in yaml (setting in result): " + e);
            result.error = e;
            return result;
        }
    }
}