add an expresson parser which understands interpolated strings etc
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParser.java b/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParser.java
new file mode 100644
index 0000000..e3c5f53
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParser.java
@@ -0,0 +1,230 @@
+/*
+ * 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.core.workflow.utils;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
+import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.BackslashParseMode;
+import org.apache.brooklyn.util.collections.MutableList;
+import org.apache.brooklyn.util.collections.MutableMap;
+import org.apache.brooklyn.util.guava.Maybe;
+import org.apache.brooklyn.util.text.Strings;
+
+import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.CharactersCollectingParseMode;
+import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.CommonParseMode;
+import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseMode;
+import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNode;
+import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNodeOrValue;
+import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseValue;
+import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.TopLevelParseMode;
+
+/** simplistic parser for workflow expressions and strings, recognizing single and double quotes, backslash escapes, and interpolated strings with ${...} */
+public abstract class ExpressionParser {
+
+ public abstract Maybe<ParseNode> parse(String input);
+ public abstract Maybe<List<ParseNodeOrValue>> parseEverything(String inputRemaining);
+
+
+ public static final ParseMode BACKSLASH_ESCAPE = new BackslashParseMode();
+ public static final ParseMode WHITESPACE = new CharactersCollectingParseMode("whitespace", Character::isWhitespace);
+
+ public static final ParseMode DOUBLE_QUOTE = CommonParseMode.transitionNested("double_quote", "\"", "\"");
+ public static final ParseMode SINGLE_QUOTE = CommonParseMode.transitionNested("single_quote", "\'", "\'");
+ public static final ParseMode INTERPOLATED = CommonParseMode.transitionNested("interpolated_expression", "${", "}");
+
+ public static final ParseMode SQUARE_BRACKET = CommonParseMode.transitionNested("square_bracket", "[", "]");
+ public static final ParseMode PARENTHESES = CommonParseMode.transitionNested("parenthesis", "(", ")");
+ public static final ParseMode CURLY_BRACES = CommonParseMode.transitionNested("curly_brace", "{", "}");
+
+
+ private static Multimap<ParseMode,ParseMode> getCommonInnerTransitions() {
+ ListMultimap<ParseMode,ParseMode> m = Multimaps.newListMultimap(MutableMap.of(), MutableList::of);
+ m.putAll(BACKSLASH_ESCAPE, MutableList.of());
+ m.putAll(SINGLE_QUOTE, MutableList.of(BACKSLASH_ESCAPE, INTERPOLATED));
+ m.putAll(DOUBLE_QUOTE, MutableList.of(BACKSLASH_ESCAPE, INTERPOLATED));
+ m.putAll(INTERPOLATED, MutableList.of(BACKSLASH_ESCAPE, DOUBLE_QUOTE, SINGLE_QUOTE, INTERPOLATED));
+ return Multimaps.unmodifiableMultimap(m);
+ }
+ public static final Multimap<ParseMode,ParseMode> COMMON_INNER_TRANSITIONS = getCommonInnerTransitions();
+ public static final List<ParseMode> COMMON_TOP_LEVEL_TRANSITIONS = MutableList.of(BACKSLASH_ESCAPE, DOUBLE_QUOTE, SINGLE_QUOTE, INTERPOLATED).asUnmodifiable();
+
+
+ public static ExpressionParserImpl newDefaultAllowingUnquotedAndSplittingOnWhitespace() {
+ return new ExpressionParserImpl(new TopLevelParseMode(true),
+ MutableList.copyOf(COMMON_TOP_LEVEL_TRANSITIONS).append(WHITESPACE),
+ COMMON_INNER_TRANSITIONS);
+ }
+ public static ExpressionParserImpl newDefaultAllowingUnquotedLiteralValues() {
+ return new ExpressionParserImpl(new TopLevelParseMode(true),
+ MutableList.copyOf(COMMON_TOP_LEVEL_TRANSITIONS),
+ COMMON_INNER_TRANSITIONS);
+ }
+ public static ExpressionParserImpl newDefaultRequiringQuotingOrExpressions() {
+ return new ExpressionParserImpl(new TopLevelParseMode(false),
+ MutableList.copyOf(COMMON_TOP_LEVEL_TRANSITIONS),
+ COMMON_INNER_TRANSITIONS);
+ }
+
+ public static ExpressionParserImpl newEmptyDefault(boolean requiresQuoting) {
+ return newEmpty(new TopLevelParseMode(!requiresQuoting));
+ }
+ public static ExpressionParserImpl newEmpty(TopLevelParseMode topLevel) {
+ return new ExpressionParserImpl(topLevel, MutableList.of());
+ }
+
+
+ public static boolean isQuotedExpressionNode(ParseNodeOrValue next) {
+ if (next==null) return false;
+ return next.isParseNodeMode(SINGLE_QUOTE) || next.isParseNodeMode(DOUBLE_QUOTE);
+ }
+
+ public static String getAllUnquoted(List<ParseNodeOrValue> mp) {
+ return join(mp, ExpressionParser::getUnquoted);
+ }
+
+ public static String getAllSource(List<ParseNodeOrValue> mp) {
+ return join(mp, ParseNodeOrValue::getSource);
+ }
+
+ public static String getUnescapedButNotUnquoted(List<ParseNodeOrValue> mp) {
+ return join(mp, ExpressionParser::getUnescapedButNotUnquoted);
+ }
+
+ public static String getUnescapedButNotUnquoted(ParseNodeOrValue mp) {
+ if (mp instanceof ParseValue) return ((ParseValue)mp).getContents();
+
+ // backslashes are unescaped
+ if (mp.isParseNodeMode(BACKSLASH_ESCAPE)) return getAllSource(((ParseNode)mp).getContents());
+
+ // in interpolations we don't unescape
+ if (mp.isParseNodeMode(INTERPOLATED)) return mp.getSource();
+ // in double quotes we don't unescape
+ if (isQuotedExpressionNode(mp)) return mp.getSource();
+
+ // everything else is recursed
+ return getContentsWithStartAndEnd((ParseNode) mp, ExpressionParser::getUnescapedButNotUnquoted);
+ }
+
+ public static String getContentsWithStartAndEnd(ParseNode mp, Function<List<ParseNodeOrValue>,String> fn) {
+ String v = fn.apply(mp.getContents());
+ ParseMode m = mp.getParseNodeModeClass();
+ if (m instanceof CommonParseMode) {
+ return Strings.toStringWithValueForNull(((CommonParseMode)m).enterOnString, "")
+ + v
+ + Strings.toStringWithValueForNull(((CommonParseMode)m).exitOnString, "");
+ }
+ return v;
+ }
+
+ public static boolean startsWithWhitespace(ParseNodeOrValue pn) {
+ if (pn.isParseNodeMode(WHITESPACE)) return true;
+ if (pn instanceof ParseValue) return ((ParseValue)pn).getContents().startsWith(" ");
+ return ((ParseNode)pn).getStartingContent().map(ExpressionParser::startsWithWhitespace).or(false);
+ }
+
+
+ public static String join(List<ParseNodeOrValue> mp, Function<ParseNodeOrValue,String> fn) {
+ return mp.stream().map(fn).collect(Collectors.joining());
+ }
+
+ public static String getUnquoted(ParseNodeOrValue mp) {
+ if (mp instanceof ParseValue) return ((ParseValue)mp).getContents();
+ ParseNode mpn = (ParseNode) mp;
+
+ // unquote, unescaping what's inside
+ if (isQuotedExpressionNode(mp)) return getUnescapedButNotUnquoted(((ParseNode) mp).getContents());
+
+ // source for anything else
+ return mp.getSource();
+ }
+
+ /** removes whitespace, either WHITESPACE nodes, or values containing whitespace */
+ public static boolean isBlank(ParseNodeOrValue n) {
+ if (n.isParseNodeMode(WHITESPACE)) return true;
+ if (n instanceof ParseValue) return Strings.isBlank(((ParseValue) n).getContents());
+ return false;
+ }
+
+ /** removes whitespace, either WHITESPACE or empty/blank ParseValue nodes,
+ * and changing ParseValues starting or ending with whitespace and the start or end (once other whitespace removed) */
+ public static List<ParseNodeOrValue> trimWhitespace(List<ParseNodeOrValue> contents) {
+ return trimWhitespace(contents, true, true);
+ }
+ public static List<ParseNodeOrValue> trimWhitespace(List<ParseNodeOrValue> contents, boolean removeFromStart, boolean removeFromEnd) {
+ List<ParseNodeOrValue> result = MutableList.of();
+ boolean changed = false;
+ Iterator<ParseNodeOrValue> ni = contents.iterator();
+ while (ni.hasNext()) {
+ ParseNodeOrValue n = ni.next();
+ if (isBlank(n)) {
+ changed = true;
+ continue;
+
+ } else {
+ if (n instanceof ParseValue) {
+ String c = ((ParseValue) n).getContents();
+ if (Character.isWhitespace(c.charAt(0))) {
+ changed = true;
+ while (!c.isEmpty() && Character.isWhitespace(c.charAt(0))) c = c.substring(1);
+ n = new ParseValue(c);
+ }
+ }
+ result.add(n);
+ break;
+ }
+ }
+ if (ni.hasNext()) {
+ // did the start - now need to identify the last non-whitespace thing, and then will need to replay it
+ ParseNodeOrValue lastNonWhite = null;
+ for (ParseNodeOrValue pn: contents) {
+ if (!isBlank(pn)) lastNonWhite = pn;
+ }
+ if (lastNonWhite==null) throw new IllegalStateException("Non-whitespace was found but then not found"); // shouldn't happen
+
+ ParseNodeOrValue n;
+ do {
+ n = ni.next();
+ if (n == lastNonWhite) break;
+ result.add(n);
+ } while (ni.hasNext());
+
+ if (n instanceof ParseValue) {
+ String c = ((ParseValue) n).getContents();
+ if (Character.isWhitespace(c.charAt(c.length()-1))) {
+ changed = true;
+ while (!c.isEmpty() && Character.isWhitespace(c.charAt(c.length()-1))) c = c.substring(0, c.length()-1);
+ n = new ParseValue(c);
+ }
+ }
+ result.add(n);
+
+ // and ignore the rest
+ }
+
+ if (!changed) return contents;
+ return result;
+ }
+
+}
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParserImpl.java b/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParserImpl.java
new file mode 100644
index 0000000..5a66648
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParserImpl.java
@@ -0,0 +1,444 @@
+/*
+ * 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.core.workflow.utils;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
+import org.apache.brooklyn.util.collections.MutableList;
+import org.apache.brooklyn.util.collections.MutableMap;
+import org.apache.brooklyn.util.guava.Maybe;
+
+/** simplistic parser for workflow expressions and strings, recognizing single and double quotes, backslash escapes, and interpolated strings with ${...} */
+public class ExpressionParserImpl extends ExpressionParser {
+
+ public interface ParseNodeOrValue {
+ String getParseNodeMode();
+ default boolean isParseNodeMode(String pm, String ...pmi) {
+ if (Objects.equals(getParseNodeMode(), pm)) return true;
+ for (String pmx: pmi) {
+ if (Objects.equals(getParseNodeMode(), pmx)) return true;
+ }
+ return false;
+ }
+ default boolean isParseNodeMode(ParseMode pm, ParseMode ...pmi) {
+ if (Objects.equals(getParseNodeMode(), pm.getModeName())) return true;
+ for (ParseMode pmx: pmi) {
+ if (Objects.equals(getParseNodeMode(), pmx.getModeName())) return true;
+ }
+ return false;
+ }
+ default boolean isParseNodeMode(Collection<ParseMode> pm) {
+ return pm.stream().anyMatch(pmx -> Objects.equals(getParseNodeMode(), pmx.getModeName()));
+ }
+ String getSource();
+ Object getContents();
+ }
+ public static class ParseValue implements ParseNodeOrValue {
+ public final static String MODE = "value";
+ final String value;
+
+ public ParseValue(String value) { this.value = value; }
+
+ @Override
+ public String getSource() {
+ return value;
+ }
+
+ @Override public String getContents() { return value; }
+
+ @Override
+ public String toString() {
+ return "["+value+"]";
+ }
+
+ @Override
+ public String getParseNodeMode() {
+ return MODE;
+ }
+ }
+ public static class ParseNode implements ParseNodeOrValue {
+ String source;
+ ParseMode mode;
+ public ParseNode(ParseMode mode, String source) {
+ this.mode = mode;
+ this.source = source;
+ }
+ public static ParseNode ofValue(ParseMode mode, String source, String value) {
+ ParseNode pr = new ParseNode(mode, source);
+ pr.contents = MutableList.of(new ParseValue(value));
+ return pr;
+ }
+ List<ParseNodeOrValue> contents = null;
+
+ public String getSource() {
+ return source;
+ }
+ @Override public String getParseNodeMode() {
+ return mode.name;
+ }
+ public ParseMode getParseNodeModeClass() {
+ return mode;
+ }
+
+ public List<ParseNodeOrValue> getContents() {
+ return contents;
+ }
+ public Maybe<ParseNodeOrValue> getOnlyContent() {
+ if (contents==null) return Maybe.absent("no contents set");
+ if (contents.size()==1) return Maybe.of(contents.iterator().next());
+ return Maybe.absent(contents.isEmpty() ? "no items" : contents.size()+" items");
+ }
+ public Maybe<ParseNodeOrValue> getFinalContent() {
+ if (contents==null) return Maybe.absent("no contents set");
+ if (contents.isEmpty()) return Maybe.absent("contents empty");
+ return Maybe.of(contents.get(contents.size() - 1));
+ }
+ public Maybe<ParseNodeOrValue> getStartingContent() {
+ if (contents==null) return Maybe.absent("no contents set");
+ if (contents.isEmpty()) return Maybe.absent("contents empty");
+ return Maybe.of(contents.get(0));
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(mode.name);
+ if (contents==null) sb.append("?");
+
+ if (contents!=null && contents.size()==1 && getOnlyContent().get() instanceof ParseValue) sb.append(contents.iterator().next().toString());
+ else {
+ sb.append("[");
+ if (contents == null) sb.append(source);
+ else sb.append(contents.stream().map(c -> c.toString()).collect(Collectors.joining()));
+ sb.append("]");
+ }
+ return sb.toString();
+ }
+ }
+
+ public static class InternalParseResult {
+ public final boolean skip;
+ public boolean earlyTerminationRequested;
+ @Nullable /** null means no error and no result */
+ public final Maybe<ParseNode> resultOrError;
+ protected InternalParseResult(boolean skip, Maybe<ParseNode> resultOrError) {
+ this.skip = skip;
+ this.resultOrError = resultOrError;
+ }
+ public static final InternalParseResult SKIP = new InternalParseResult(true, null);
+
+ public static InternalParseResult of(ParseNode parseModeResult) {
+ return new InternalParseResult(false, Maybe.of(parseModeResult));
+ }
+ public static InternalParseResult ofValue(ParseMode mode, String source, String value) {
+ return of(ParseNode.ofValue(mode, source, value));
+ }
+ public static InternalParseResult ofError(String message) {
+ return new InternalParseResult(false, Maybe.absent(message));
+ }
+ public static InternalParseResult ofError(Throwable t) {
+ return new InternalParseResult(false, Maybe.absent(t));
+ }
+ }
+
+ public abstract static class ParseMode {
+ final String name;
+
+ public ParseMode(String name) {
+ this.name = name;
+ }
+
+ public String getModeName() {
+ return name;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ public abstract InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> allowedTransitions, @Nullable Collection<ParseMode> transitionsAllowedHereOverride);
+ }
+
+ public static class CommonParseMode extends ParseMode {
+ public final String enterOnString;
+ public final String exitOnString;
+
+ /** parses anything starting with 'enterOnString'; if exitOnString is not null, it does so until 'exitOnString' is encountered, then reverts to previous mode */
+ protected CommonParseMode(String name, String enterOnString, @Nullable String exitOnString) {
+ super(name);
+ Preconditions.checkNotNull(enterOnString);
+ this.enterOnString = enterOnString;
+ this.exitOnString = exitOnString;
+ }
+
+ /** parses anything starting with 'enterOnString', until another mode is entered */
+ public static CommonParseMode transitionSimple(String name, String enterOnString) {
+ Preconditions.checkNotNull(enterOnString);
+ Preconditions.checkArgument(!enterOnString.isEmpty());
+ return new CommonParseMode(name, enterOnString, null);
+ }
+
+ /** parses anything starting with 'enterOnString', until 'exitOnString' is reached when it reverts to previous mode */
+ public static CommonParseMode transitionNested(String name, String enterOnString, String exitOnString) {
+ Preconditions.checkNotNull(exitOnString);
+ Preconditions.checkArgument(!exitOnString.isEmpty());
+ return new CommonParseMode(name, enterOnString, exitOnString);
+ }
+
+ public boolean allowedToTakeUnmatchedCharsAsLiteralValue() { return true; /** currently all non-top-level custom parse modes do this; but could be overridden */ }
+ public boolean allowedToExitBeforeExitStringEncountered() { return false; /** currently all non-top-level custom parse modes do not this; but could be overridden */ }
+
+ public InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> allowedTransitions, Collection<ParseMode> transitionsAllowedHereOverride) {
+ if (!input.substring(offset).startsWith(enterOnString)) return InternalParseResult.SKIP;
+
+ ParseMode currentLevelMode = this;
+ ParseNode result = new ParseNode(currentLevelMode, input);
+ result.contents = MutableList.of();
+ int i=offset;
+ if (!input.substring(i).startsWith(enterOnString))
+ return InternalParseResult.ofError("wrong start sequence for " + currentLevelMode + " at position " + i);
+ i+= enterOnString.length();
+
+ int last = i;
+ boolean exitStringEncountered = false;
+ boolean earlyTerminationRequested = false;
+ if (transitionsAllowedHereOverride==null) transitionsAllowedHereOverride = allowedTransitions.get(currentLevelMode);
+ if (transitionsAllowedHereOverride==null || transitionsAllowedHereOverride.isEmpty()) throw new IllegalStateException("Mode '"+currentLevelMode+"' is not configured with any transitions");
+ input: while (i<input.length()) {
+ if (exitOnString !=null && input.substring(i).startsWith(exitOnString)) {
+ if (i>last) result.contents.add(new ParseValue(input.substring(last, i)));
+ i+= exitOnString.length();
+ last = i;
+ exitStringEncountered = true;
+ break;
+ }
+ Maybe<ParseNode> error = null;
+ for (ParseMode candidate: transitionsAllowedHereOverride) {
+ InternalParseResult cpr = candidate.parse(input, i, allowedTransitions, null);
+ if (cpr.skip) continue;
+ if (cpr.resultOrError.isPresent()) {
+ if (i>last) result.contents.add(new ParseValue(input.substring(last, i)));
+ result.contents.add(cpr.resultOrError.get());
+ i += cpr.resultOrError.get().source.length();
+ last = i;
+ if (cpr.earlyTerminationRequested) {
+ earlyTerminationRequested = true;
+ break input;
+ }
+ continue input;
+ }
+ if (error == null) {
+ error = cpr.resultOrError;
+ } else {
+ // multiple possible modes, not used currently;
+ // only take error from first mode
+ }
+ }
+ if (!allowedToTakeUnmatchedCharsAsLiteralValue() && error==null) {
+ if (allowedToExitBeforeExitStringEncountered()) break;
+ error = Maybe.absent("Characters starting at " + i + " not permitted for " + currentLevelMode);
+ }
+ if (error!=null) return InternalParseResult.ofError(Maybe.Absent.getException(error));
+ i++;
+ }
+
+ if (!exitStringEncountered && !allowedToExitBeforeExitStringEncountered()) return InternalParseResult.ofError("Non-terminated "+currentLevelMode.name);
+ if (i>last) result.contents.add(new ParseValue(input.substring(last, i)));
+ last = i;
+ result.source = input.substring(offset, last);
+ InternalParseResult resultIPR = InternalParseResult.of(result);
+ Maybe<ParseNodeOrValue> lastPMR = result.getFinalContent();
+ if (earlyTerminationRequested) {
+ resultIPR.earlyTerminationRequested = true;
+ }
+ return resultIPR;
+ }
+ }
+
+ public static class BackslashParseMode extends ParseMode {
+ public static final Map<Character,String> COMMON_ESAPES = MutableMap.of(
+ 'n', "\n",
+ 'r', "\r",
+ 't', "\t",
+ '0', "\0"
+ // these are supported because by default we support the same character
+// '\'', "\'",
+// '\"', "\""
+ ).asUnmodifiable();
+
+ public BackslashParseMode() { super(MODE); }
+ final static String MODE = "backslash_escape";
+ final static String START = "\\";
+ @Override
+ public InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> _allowedTransitions, Collection<ParseMode> _transitionsAllowedHereOverride) {
+ if (!input.substring(offset).startsWith(START)) return InternalParseResult.SKIP;
+ if (input.substring(offset).length()>=2) {
+ char c = input.charAt(offset+1);
+ return InternalParseResult.ofValue(this, input.substring(offset, offset+2), Maybe.ofDisallowingNull(COMMON_ESAPES.get(c)).or(() -> ""+c));
+ } else {
+ return InternalParseResult.ofError("Backslash escape character not permitted at end of string");
+ }
+ }
+ }
+
+ public static class CharactersCollectingParseMode extends ParseMode {
+ final Predicate<Character> charactersAcceptable;
+ public CharactersCollectingParseMode(String name, Predicate<Character> charactersAcceptable) {
+ super(name);
+ this.charactersAcceptable = charactersAcceptable;
+ }
+ public CharactersCollectingParseMode(String name, char c) {
+ this(name, cx -> Objects.equals(cx, c));
+ }
+ @Override
+ public InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> _allowedTransitions, Collection<ParseMode> _transitionsAllowedHereOverride) {
+ int i=offset;
+ while (i<input.length() && charactersAcceptable.test(input.charAt(i))) {
+ i++;
+ }
+ if (i>offset) {
+ String v = input.substring(offset, i);
+ return InternalParseResult.ofValue(this, v, v);
+ } else {
+ return InternalParseResult.SKIP;
+ }
+ }
+ }
+
+ public static class TopLevelParseMode extends CommonParseMode {
+ private final boolean allowsValues;
+ private final List<ParseMode> allowedTransitions = MutableList.of();
+ public List<String> mustEndWithOneOfTheseModes;
+
+ protected TopLevelParseMode(boolean allowsUnquotedLiteralValues) {
+ super("top-level", "", null);
+ this.allowsValues = allowsUnquotedLiteralValues;
+ }
+
+ @Override
+ public boolean allowedToTakeUnmatchedCharsAsLiteralValue() {
+ return allowsValues;
+ }
+
+ @Override
+ public boolean allowedToExitBeforeExitStringEncountered() {
+ return true;
+ }
+
+ @Override
+ public InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> allowedTransitions, Collection<ParseMode> transitionsAllowedHereOverride) {
+ InternalParseResult result = super.parse(input, offset, allowedTransitions, transitionsAllowedHereOverride);
+ if (mustEndWithOneOfTheseModes!=null && result.resultOrError.isPresent()) {
+ String error = result.resultOrError.get().getFinalContent().map(pn ->
+ mustEndWithOneOfTheseModes.contains(pn.getParseNodeMode())
+ ? null
+ : "Expression ends with " + pn.getParseNodeMode() + " but should have ended with required token: " + mustEndWithOneOfTheseModes)
+ .or("Expression is empty but was expected to end with required token: " + mustEndWithOneOfTheseModes);
+ if (error!=null) return InternalParseResult.ofError(error);
+ }
+ return result;
+ }
+ }
+
+ private final Multimap<ParseMode,ParseMode> allowedTransitions = Multimaps.newListMultimap(MutableMap.of(), MutableList::of);
+ private final TopLevelParseMode topLevel;
+
+ protected ExpressionParserImpl(TopLevelParseMode topLevel, List<ParseMode> allowedAtTopLevel, Multimap<ParseMode,ParseMode> allowedTransitions) {
+ this(topLevel, allowedAtTopLevel);
+ this.allowedTransitions.putAll(allowedTransitions);
+ }
+ protected ExpressionParserImpl(TopLevelParseMode topLevel, List<ParseMode> allowedAtTopLevel) {
+ this.topLevel = topLevel;
+ this.topLevel.allowedTransitions.addAll(allowedAtTopLevel);
+ }
+
+ public ExpressionParserImpl includeAllowedSubmodeTransition(ParseMode parent, ParseMode allowedSubmode, ParseMode ...allowedOtherSubmodes) {
+ allowedTransitions.put(parent, allowedSubmode);
+ for (ParseMode m: allowedOtherSubmodes) allowedTransitions.put(parent, m);
+ return this;
+ }
+ public ExpressionParserImpl includeGroupingBracketsAtUsualPlaces(ParseMode ...optionalExplicitBrackets) {
+ List<ParseMode> brackets = optionalExplicitBrackets==null ? MutableList.of() :
+ Arrays.asList(optionalExplicitBrackets).stream().filter(b -> b!=null).collect(Collectors.toList());
+ if (brackets.isEmpty()) brackets = MutableList.of(SQUARE_BRACKET, CURLY_BRACES, PARENTHESES);
+
+ brackets.forEach(b -> includeAllowedTopLevelTransition(b));
+ allowedTransitions.putAll(INTERPOLATED, brackets);
+ for (ParseMode b : brackets) {
+ allowedTransitions.putAll(b, brackets);
+ includeAllowedSubmodeTransition(b, INTERPOLATED);
+ includeAllowedSubmodeTransition(b, SINGLE_QUOTE);
+ includeAllowedSubmodeTransition(b, DOUBLE_QUOTE);
+ brackets.forEach(bb -> includeAllowedSubmodeTransition(b, bb));
+ }
+ return this;
+ }
+ public ExpressionParserImpl includeAllowedTopLevelTransition(ParseMode allowedAtTopLevel) {
+ topLevel.allowedTransitions.add(allowedAtTopLevel);
+ return this;
+ }
+ public ExpressionParserImpl removeAllowedSubmodeTransition(ParseMode parent, ParseMode disallowedSubmode) {
+ allowedTransitions.remove(parent, disallowedSubmode);
+ return this;
+ }
+ public ExpressionParserImpl removeAllowedTopLevelTransition(ParseMode disallowedSubmode) {
+ topLevel.allowedTransitions.remove(disallowedSubmode);
+ return this;
+ }
+
+ public Maybe<ParseNode> parse(String input) {
+ return topLevel.parse(input, 0, allowedTransitions, topLevel.allowedTransitions).resultOrError;
+ }
+ public Maybe<List<ParseNodeOrValue>> parseEverything(String input) {
+ return parse(input).mapMaybe(pr -> {
+ if (!Objects.equals(pr.source, input)) return Maybe.absent("Could not parse everything");
+ return Maybe.of(pr.contents);
+ });
+ }
+
+ public ExpressionParserImpl stoppingAt(String id, Predicate<String> earlyTermination, boolean requireTopLevelToEndWithThisOrAnotherRequiredMode) {
+ if (requireTopLevelToEndWithThisOrAnotherRequiredMode) {
+ if (topLevel.mustEndWithOneOfTheseModes == null) topLevel.mustEndWithOneOfTheseModes = MutableList.of();
+ topLevel.mustEndWithOneOfTheseModes.add(id);
+ }
+ return includeAllowedTopLevelTransition(new ParseMode(id) {
+ @Override
+ public InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> allowedTransitions, @Nullable Collection<ParseMode> transitionsAllowedHereOverride) {
+ if (earlyTermination.test(input.substring(offset))) {
+ InternalParseResult ipr = InternalParseResult.ofValue(this, "", "");
+ ipr.earlyTerminationRequested = true;
+ return ipr;
+ }
+ return InternalParseResult.SKIP;
+ }
+ });
+ }
+
+}
diff --git a/core/src/test/java/org/apache/brooklyn/core/workflow/ExpressionParserTest.java b/core/src/test/java/org/apache/brooklyn/core/workflow/ExpressionParserTest.java
new file mode 100644
index 0000000..d3d4fad
--- /dev/null
+++ b/core/src/test/java/org/apache/brooklyn/core/workflow/ExpressionParserTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.core.workflow;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.brooklyn.core.workflow.utils.ExpressionParser;
+import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNodeOrValue;
+import org.apache.brooklyn.test.Asserts;
+import org.apache.brooklyn.util.guava.Maybe;
+import org.testng.annotations.Test;
+
+public class ExpressionParserTest {
+
+ static String s(List<ParseNodeOrValue> pr) {
+ return pr.stream().map(ParseNodeOrValue::toString).collect(Collectors.joining(""));
+ }
+ static String parseFull(ExpressionParser ep, String expression) {
+ return getParseTreeString(ep.parseEverything(expression).get());
+ }
+ static String parseFull(String expression) {
+ return parseFull(ExpressionParser.newDefaultAllowingUnquotedLiteralValues(), expression);
+ }
+ static String parseFullWhitespace(String expression) {
+ return parseFull(ExpressionParser.newDefaultAllowingUnquotedAndSplittingOnWhitespace(), expression);
+ }
+ static String parsePartial(ExpressionParser ep, String expression) {
+ return getParseTreeString(ep.parse(expression).get().getContents());
+ }
+
+ static String getParseTreeString(List<ParseNodeOrValue> result) {
+ return result.stream().map(ParseNodeOrValue::toString).collect(Collectors.joining(""));
+ }
+
+ @Test
+ public void testCommon() {
+ Asserts.assertEquals(parseFull("hello"), "[hello]");
+ Asserts.assertEquals(parseFull("\"hello\""), "double_quote[hello]");
+ Asserts.assertEquals(parseFull("${a}"), "interpolated_expression[a]");
+
+ Asserts.assertEquals(parseFull("\"hello\"world"), "double_quote[hello][world]");
+ Asserts.assertEquals(parseFull("\"hello\" with 'sq \"'"), "double_quote[hello][ with ]single_quote[sq \"]");
+ Asserts.assertEquals(parseFullWhitespace("\"hello\" with 'sq \"'"), "double_quote[hello]whitespace[ ][with]whitespace[ ]single_quote[sq \"]");
+
+ Asserts.assertEquals(parseFull("\"hello ${a}\""), "double_quote[[hello ]interpolated_expression[a]]");
+ Asserts.assertEquals(parseFull("x[\"v\"] =1"), "[x[]double_quote[v][] =1]");
+ }
+
+ @Test
+ public void testError() {
+ Asserts.expectedFailureContains(Maybe.Absent.getException(ExpressionParser.newDefaultAllowingUnquotedLiteralValues().parseEverything("\"non-quoted string")),
+ "Non-terminated double_quote");
+ }
+
+ @Test
+ public void testPartial() {
+ ExpressionParser ep = ExpressionParser.newDefaultAllowingUnquotedLiteralValues().
+ stoppingAt("equals", s -> s.startsWith("="), true);
+ Asserts.assertEquals(parsePartial(ep, "x=1"), "[x]equals[]");
+ Asserts.assertEquals(parsePartial(ep, "x[\"v\"] =1"), "[x[]double_quote[v][] ]equals[]");
+ // equals not matched in expression
+ Asserts.assertFailsWith(() -> parsePartial(ep, "x[\"=\"] is 1"),
+ Asserts.expectedFailureContains("value", "should", "end", "with", "required", "equals"));
+ }
+
+ @Test
+ public void testBrackets() {
+ ExpressionParser ep1 = ExpressionParser.newDefaultAllowingUnquotedLiteralValues().
+ includeGroupingBracketsAtUsualPlaces();
+ Asserts.assertEquals(parseFull(ep1, "x[\"v\"] =1"), "[x]square_bracket[double_quote[v]][ =1]");
+
+ ExpressionParser ep2 = ExpressionParser.newDefaultAllowingUnquotedLiteralValues().
+ includeGroupingBracketsAtUsualPlaces().
+ stoppingAt("equals", s -> s.startsWith("="), true);
+ Asserts.assertEquals(parsePartial(ep2, "x[\"v\"] =1"), "[x]square_bracket[double_quote[v]][ ]equals[]");
+ Asserts.assertFailsWith(() -> parsePartial(ep2, "x[\"=\"] is 1"),
+ Asserts.expectedFailureContains("value", "should", "end", "with", "required", "equals"));
+ }
+
+}
diff --git a/utils/common/src/main/java/org/apache/brooklyn/test/Asserts.java b/utils/common/src/main/java/org/apache/brooklyn/test/Asserts.java
index 4aba9f1..fdfe11a 100644
--- a/utils/common/src/main/java/org/apache/brooklyn/test/Asserts.java
+++ b/utils/common/src/main/java/org/apache/brooklyn/test/Asserts.java
@@ -32,6 +32,7 @@
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Predicate;
import com.google.common.annotations.Beta;
import com.google.common.base.Predicates;
@@ -1337,6 +1338,12 @@
}
return true;
}
+ public static Predicate<Throwable> expectedFailureContains(String phrase1ToContain, String ...optionalOtherPhrasesToContain) {
+ return e -> expectedFailureContains(e, phrase1ToContain, optionalOtherPhrasesToContain);
+ }
+ public static Predicate<Throwable> expectedFailureContainsIgnoreCase(String phrase1ToContain, String ...optionalOtherPhrasesToContain) {
+ return e -> expectedFailureContainsIgnoreCase(e, phrase1ToContain, optionalOtherPhrasesToContain);
+ }
/** As {@link #expectedFailureContains(Throwable, String, String...)} but case insensitive */
public static boolean expectedCompoundExceptionContainsIgnoreCase(CompoundRuntimeException e, String phrase1ToContain, String ...optionalOtherPhrasesToContain) {
diff --git a/utils/common/src/main/java/org/apache/brooklyn/util/guava/Maybe.java b/utils/common/src/main/java/org/apache/brooklyn/util/guava/Maybe.java
index ff807dd..0cd3d03 100644
--- a/utils/common/src/main/java/org/apache/brooklyn/util/guava/Maybe.java
+++ b/utils/common/src/main/java/org/apache/brooklyn/util/guava/Maybe.java
@@ -238,6 +238,12 @@
};
}
+ public <T2> Maybe<T2> asType(Class<T2> requiredClass) {
+ if (isAbsent()) return Maybe.castAbsent(this);
+ if (requiredClass.isInstance(get())) return Maybe.<T2>cast((Maybe)this);
+ return Maybe.absent(() -> new IllegalArgumentException("Value is not of required type "+requiredClass));
+ }
+
public static class MaybeGuavaOptional<T> extends Maybe<T> {
private static final long serialVersionUID = -823731500051341455L;
private final Optional<T> value;