This is an automated email from the ASF dual-hosted git repository.

heneveld pushed a commit to branch expression-parsing
in repository https://gitbox.apache.org/repos/asf/brooklyn-server.git

commit 95271552c3829afba3f058a56ecd4258fbc6a802
Author: Alex Heneveld <a...@cloudsoft.io>
AuthorDate: Tue Feb 6 07:56:02 2024 +0000

    add an expresson parser which understands interpolated strings etc
---
 .../core/workflow/utils/ExpressionParser.java      | 230 +++++++++++
 .../core/workflow/utils/ExpressionParserImpl.java  | 444 +++++++++++++++++++++
 .../core/workflow/ExpressionParserTest.java        |  97 +++++
 .../java/org/apache/brooklyn/test/Asserts.java     |   7 +
 .../java/org/apache/brooklyn/util/guava/Maybe.java |   6 +
 5 files changed, 784 insertions(+)

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 0000000000..e3c5f53ac0
--- /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 0000000000..5a66648b28
--- /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 0000000000..d3d4fade44
--- /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 4aba9f1025..fdfe11ab96 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.Callable;
 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 @@ public class Asserts {
         }
         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 ff807dd962..0cd3d03746 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 abstract class Maybe<T> implements Serializable, 
Supplier<T> {
         };
     }
 
+    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;


Reply via email to