blob: aa11aa548fc8c12876d33e081108619b8dac568d [file] [log] [blame]
/*
* 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 java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
import com.google.common.annotations.Beta;
import com.google.common.base.Function;
import com.google.common.collect.Iterables;
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.internal.BrooklynSystemProperties;
import org.apache.brooklyn.util.javalang.coerce.PrimitiveStringTypeCoercions;
import org.apache.brooklyn.util.text.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Constructor;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import org.yaml.snakeyaml.error.Mark;
import org.yaml.snakeyaml.inspector.TagInspector;
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 org.yaml.snakeyaml.nodes.Tag;
public class Yamls {
private static final Logger log = LoggerFactory.getLogger(Yamls.class);
private static Yaml newYaml() {
LoaderOptions loaderOptions = new LoaderOptions();
if (BrooklynSystemProperties.YAML_TYPE_INSTANTIATION.isEnabled()) {
loaderOptions.setTagInspector(new TagInspector() {
@Override
public boolean isGlobalTagAllowed(Tag tag) {
return true;
}
});
}
return new Yaml(
BrooklynSystemProperties.YAML_TYPE_INSTANTIATION.isEnabled()
? new ConstructorExcludingNonNumbers(loaderOptions) // allows instantiation of arbitrary Java types
: new SafeConstructorExcludingNonNumbers(loaderOptions) // allows instantiation of limited set of types only
);
}
private static class ConstructorExcludingNonNumbers extends Constructor {
public ConstructorExcludingNonNumbers(LoaderOptions loaderOptions) {
super(loaderOptions);
this.yamlConstructors.put(Tag.FLOAT, new ConstructYamlFloatExcludingNonNumbers());
}
class ConstructYamlFloatExcludingNonNumbers extends ConstructYamlFloat {
@Override
public Object construct(Node node) {
return numericDoublesOnly(super.construct(node), node);
}
}
}
private static class SafeConstructorExcludingNonNumbers extends SafeConstructor {
public SafeConstructorExcludingNonNumbers(LoaderOptions loaderOptions) {
super(loaderOptions);
this.yamlConstructors.put(Tag.FLOAT, new ConstructYamlFloatExcludingNonNumbers());
}
class ConstructYamlFloatExcludingNonNumbers extends ConstructYamlFloat {
@Override
public Object construct(Node node) {
return numericDoublesOnly(super.construct(node), node);
}
}
}
private static Object numericDoublesOnly(Object construct, Node node) {
if (PrimitiveStringTypeCoercions.isNanOrInf(construct)) {
throw new IllegalStateException("YAML parser forbids out of range doubles; consider wrapping as string and coercing to type BigDecimal: "+node);
}
return construct;
}
/** 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 #getAtPreParsed(Object, List)
*/
@Beta
public static Object getAt(String yaml, List<String> path) {
Iterable<Object> result = newYaml().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 = newYaml().loadAll(yaml);
return getAs(result, List.class);
}
/** as {@link #parseAll(String)} */
@SuppressWarnings("unchecked")
public static Iterable<Object> parseAll(Reader yaml) {
Iterable<Object> result = newYaml().loadAll(yaml);
return 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#found()} 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 = newYaml().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);
if (log.isTraceEnabled()) log.trace("Unable to find element in yaml (setting in result): "+e);
result.error = e;
return result;
}
}
static class LastDocumentFunction implements Function<String,String> {
@Override
public String apply(String input) {
if (input==null) return null;
Matcher match = Pattern.compile("^---$[\\n\\r]?", Pattern.MULTILINE).matcher(input);
int lastEnd = 0;
while (match.find()) {
lastEnd = match.end();
}
return input.substring(lastEnd);
}
}
private static final LastDocumentFunction LAST_DOCUMENT_FUNCTION_INSTANCE = new LastDocumentFunction();
public static Function<String,String> lastDocumentFunction() {
return LAST_DOCUMENT_FUNCTION_INSTANCE;
}
}