update shorthand processor to use expression processor

allows us to respect spaces and other things inside brackets
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessor.java b/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessor.java
index 32ba2f8..0adbaff 100644
--- a/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessor.java
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessor.java
@@ -18,337 +18,36 @@
  */
 package org.apache.brooklyn.core.workflow;
 
-import org.apache.brooklyn.util.collections.CollectionMerger;
-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.QuotedStringTokenizer;
-import org.apache.brooklyn.util.text.Strings;
-
-import java.util.Arrays;
-import java.util.List;
 import java.util.Map;
-import java.util.function.Consumer;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
+
+import org.apache.brooklyn.util.guava.Maybe;
 
 /**
- * Accepts a shorthand template, and converts it to a map of values,
- * e.g. given template "[ ?${type_set} ${sensor.type} ] ${sensor.name} \"=\" ${value}"
- * and input "integer foo=3", this will return
- * { sensor: { type: integer, name: foo }, value: 3, type_set: true }.
- *
- * Expects space-separated TOKEN where TOKEN is either:
- *
- * ${VAR} - to set VAR, which should be of the regex [A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*, with dot separation used to set nested maps;
- *   will match a quoted string if supplied, else up to the next literal if the next token is a literal, else the next work.
- * ${VAR...} - as above, but will collect multiple args if needed (if the next token is a literal matched further on, or if at end of word)
- * "LITERAL" - to expect a literal expression. this must include the quotation marks and should include spaces if spaces are required.
- * [ TOKEN ] - to indicate TOKEN is optional, where TOKEN is one of the above sections. parsing is attempted first with it, then without it.
- * [ ?${VAR} TOKEN ] - as `[ TOKEN ]` but VAR is set true or false depending whether this optional section was matched.
- *
- * Would be nice to support A | B (exclusive or) for A or B but not both (where A might contain a literal for disambiguation),
- * and ( X ) for X required but grouped (for use with | (exclusive or) where one option is required).
- * Would also be nice to support any order, which could be ( A & B ) to allow A B or B A.
- *
- * But for now we've made do without it, with some compromises:
- * * keywords must follow the order indicated
- * * exclusive alternatives are disallowed by code subsequently or checked separately (eg Transform)
+ * This impl delegates to one of the various classes that do this -- see notes in individual ones.
  */
 public class ShorthandProcessor {
 
-    private final String template;
-    boolean finalMatchRaw = false;
-    boolean failOnMismatch = true;
+    ShorthandProcessorEpToQst delegate;
 
     public ShorthandProcessor(String template) {
-        this.template = template;
+        delegate = new ShorthandProcessorEpToQst(template);
     }
 
     public Maybe<Map<String,Object>> process(String input) {
-        return new ShorthandProcessorAttempt(this, input).call();
+        return delegate.process(input);
     }
 
     /** whether the last match should preserve quotes and spaces; default false */
     public ShorthandProcessor withFinalMatchRaw(boolean finalMatchRaw) {
-        this.finalMatchRaw = finalMatchRaw;
+        delegate.withFinalMatchRaw(finalMatchRaw);
         return this;
     }
 
     /** whether to fail on mismatched quotes in the input, default true */
     public ShorthandProcessor withFailOnMismatch(boolean failOnMismatch) {
-        this.failOnMismatch = failOnMismatch;
+        // only supported for some
+        delegate.withFailOnMismatch(failOnMismatch);
         return this;
     }
 
-    static class ShorthandProcessorAttempt {
-        private final List<String> templateTokens;
-        private final String inputOriginal;
-        private final QuotedStringTokenizer qst;
-        private final String template;
-        private final ShorthandProcessor options;
-        int optionalDepth = 0;
-        int optionalSkippingInput = 0;
-        private String inputRemaining;
-        Map<String, Object> result;
-        Consumer<String> valueUpdater;
-
-        ShorthandProcessorAttempt(ShorthandProcessor proc, String input) {
-            this.template = proc.template;
-            this.options = proc;
-            this.qst = qst(template);
-            this.templateTokens = qst.remainderAsList();
-            this.inputOriginal = input;
-        }
-
-        private QuotedStringTokenizer qst(String x) {
-            return QuotedStringTokenizer.builder().includeQuotes(true).includeDelimiters(false).expectQuotesDelimited(true).failOnOpenQuote(options.failOnMismatch).build(x);
-        }
-
-        public synchronized Maybe<Map<String,Object>> call() {
-            if (result == null) {
-                result = MutableMap.of();
-                inputRemaining = inputOriginal;
-            } else {
-                throw new IllegalStateException("Only allowed to use once");
-            }
-            Maybe<Object> error = doCall();
-            if (error.isAbsent()) return Maybe.Absent.castAbsent(error);
-            inputRemaining = Strings.trimStart(inputRemaining);
-            if (Strings.isNonBlank(inputRemaining)) {
-                if (valueUpdater!=null) {
-                    QuotedStringTokenizer qstInput = qst(inputRemaining);
-                    valueUpdater.accept(getRemainderPossiblyRaw(qstInput));
-                } else {
-                    // shouldn't come here
-                    return Maybe.absent("Input has trailing characters after template is matched: '" + inputRemaining + "'");
-                }
-            }
-            return Maybe.of(result);
-        }
-
-        protected Maybe<Object> doCall() {
-            boolean isEndOfOptional = false;
-            outer: while (true) {
-                if (isEndOfOptional) {
-                    if (optionalDepth <= 0) {
-                        throw new IllegalStateException("Unexpected optional block closure");
-                    }
-                    optionalDepth--;
-                    if (optionalSkippingInput>0) {
-                        // we were in a block where we skipped something optional because it couldn't be matched; outer parser is now canonical,
-                        // and should stop skipping
-                        return Maybe.of(true);
-                    }
-                    isEndOfOptional = false;
-                }
-
-                if (templateTokens.isEmpty()) {
-                    if (Strings.isNonBlank(inputRemaining) && valueUpdater==null) {
-                        return Maybe.absent("Input has trailing characters after template is matched: '" + inputRemaining + "'");
-                    }
-                    if (optionalDepth>0)
-                        return Maybe.absent("Mismatched optional marker in template");
-                    return Maybe.of(true);
-                }
-                String t = templateTokens.remove(0);
-
-                if (t.startsWith("[")) {
-                    t = t.substring(1);
-                    if (!t.isEmpty()) {
-                        templateTokens.add(0, t);
-                    }
-                    String optionalPresentVar = null;
-                    if (!templateTokens.isEmpty() && templateTokens.get(0).startsWith("?")) {
-                        String v = templateTokens.remove(0);
-                        if (v.startsWith("?${") && v.endsWith("}")) {
-                            optionalPresentVar = v.substring(3, v.length() - 1);
-                        } else {
-                            throw new IllegalStateException("? after [ should indicate optional presence variable using syntax '?${var}', not '"+v+"'");
-                        }
-                    }
-                    Maybe<Object> cr;
-                    if (optionalSkippingInput<=0) {
-                        // make a deep copy so that valueUpdater writes get replayed
-                        Map<String, Object> backupResult = (Map) CollectionMerger.builder().deep(true).build().merge(MutableMap.of(), result);
-                        Consumer<String> backupValueUpdater = valueUpdater;
-                        String backupInputRemaining = inputRemaining;
-                        List<String> backupTemplateTokens = MutableList.copyOf(templateTokens);
-                        int oldDepth = optionalDepth;
-                        int oldSkippingDepth = optionalSkippingInput;
-
-                        optionalDepth++;
-                        cr = doCall();
-                        if (cr.isPresent()) {
-                            // succeeded
-                            if (optionalPresentVar!=null) result.put(optionalPresentVar, true);
-                            continue;
-
-                        } else {
-                            // restore
-                            result = backupResult;
-                            valueUpdater = backupValueUpdater;
-                            if (optionalPresentVar!=null) result.put(optionalPresentVar, false);
-                            inputRemaining = backupInputRemaining;
-                            templateTokens.clear();
-                            templateTokens.addAll(backupTemplateTokens);
-                            optionalDepth = oldDepth;
-                            optionalSkippingInput = oldSkippingDepth;
-
-                            optionalSkippingInput++;
-                            optionalDepth++;
-                            cr = doCall();
-                            if (cr.isPresent()) {
-                                optionalSkippingInput--;
-                                continue;
-                            }
-                        }
-                    } else {
-                        if (optionalPresentVar!=null) {
-                            result.put(optionalPresentVar, false);
-                            valueUpdater = null;
-                        }
-                        optionalDepth++;
-                        cr = doCall();
-                        if (cr.isPresent()) {
-                            continue;
-                        }
-                    }
-                    return cr;
-                }
-
-                isEndOfOptional = t.endsWith("]");
-
-                if (isEndOfOptional) {
-                    t = t.substring(0, t.length() - 1);
-                    if (t.isEmpty()) continue;
-                    // next loop will process the end of the optionality
-                }
-
-                if (qst.isQuoted(t)) {
-                    if (optionalSkippingInput>0) continue;
-
-                    String literal = qst.unwrapIfQuoted(t);
-                    do {
-                        // ignore leading spaces (since the quoted string tokenizer will have done that anyway); but their _absence_ can be significant for intra-token searching when matching a var
-                        inputRemaining = Strings.trimStart(inputRemaining);
-                        if (inputRemaining.startsWith(Strings.trimStart(literal))) {
-                            // literal found
-                            inputRemaining = inputRemaining.substring(Strings.trimStart(literal).length());
-                            continue outer;
-                        }
-                        if (inputRemaining.isEmpty()) return Maybe.absent("Literal '"+literal+"' expected, when end of input reached");
-                        if (valueUpdater!=null) {
-                            QuotedStringTokenizer qstInput = qst(inputRemaining);
-                            if (!qstInput.hasMoreTokens()) return Maybe.absent("Literal '"+literal+"' expected, when end of input tokens reached");
-                            String value = getNextInputTokenUpToPossibleExpectedLiteral(qstInput, literal);
-                            valueUpdater.accept(value);
-                            continue;
-                        }
-                        return Maybe.absent("Literal '"+literal+"' expected, when encountered '"+inputRemaining+"'");
-                    } while (true);
-                }
-
-                if (t.startsWith("${") && t.endsWith("}")) {
-                    if (optionalSkippingInput>0) continue;
-
-                    t = t.substring(2, t.length()-1);
-                    String value;
-
-                    inputRemaining = inputRemaining.trim();
-                    QuotedStringTokenizer qstInput = qst(inputRemaining);
-                    if (!qstInput.hasMoreTokens()) return Maybe.absent("End of input when looking for variable "+t);
-
-                    if (!templateTokens.stream().filter(x -> !x.equals("]")).findFirst().isPresent()) {
-                        // last word (whether optional or not) takes everything
-                        value = getRemainderPossiblyRaw(qstInput);
-                        inputRemaining = "";
-
-                    } else {
-                        value = getNextInputTokenUpToPossibleExpectedLiteral(qstInput, null);
-                    }
-                    boolean multiMatch = t.endsWith("...");
-                    if (multiMatch) t = Strings.removeFromEnd(t, "...");
-                    String keys[] = t.split("\\.");
-                    final String tt = t;
-                    valueUpdater = v2 -> {
-                        Map target = result;
-                        for (int i=0; i<keys.length; i++) {
-                            if (!Pattern.compile("[A-Za-z0-9_-]+").matcher(keys[i]).matches()) {
-                                throw new IllegalArgumentException("Invalid variable '"+tt+"'");
-                            }
-                            if (i == keys.length - 1) {
-                                target.compute(keys[i], (k, v) -> v == null ? v2 : v + " " + v2);
-                            } else {
-                                // need to make sure we have a map or null
-                                target = (Map) target.compute(keys[i], (k, v) -> {
-                                    if (v == null) return MutableMap.of();
-                                    if (v instanceof Map) return v;
-                                    return Maybe.absent("Cannot process shorthand for " + Arrays.asList(keys) + " because entry '" + k + "' is not a map (" + v + ")");
-                                });
-                            }
-                        }
-                    };
-                    valueUpdater.accept(value);
-                    if (!multiMatch) valueUpdater = null;
-                    continue;
-                }
-
-                // unexpected token
-                return Maybe.absent("Unexpected token in shorthand pattern '"+template+"' at position "+(template.lastIndexOf(t)+1));
-            }
-        }
-
-        private String getRemainderPossiblyRaw(QuotedStringTokenizer qstInput) {
-            String value;
-            value = Strings.join(qstInput.remainderRaw(), "");
-            if (!options.finalMatchRaw) {
-                return qstInput.unwrapIfQuoted(value);
-            }
-            return value;
-        }
-
-        private String getNextInputTokenUpToPossibleExpectedLiteral(QuotedStringTokenizer qstInput, String nextLiteral) {
-            String value;
-            String v = qstInput.nextToken();
-            if (qstInput.isQuoted(v)) {
-                // input was quoted, eg "\"foo=b\" ..." -- ignore the = in "foo=b"
-                value = qstInput.unwrapIfQuoted(v);
-                inputRemaining = inputRemaining.substring(v.length());
-            } else {
-                // input not quoted, if next template token is literal, look for it
-                boolean isLiteralExpected;
-                if (nextLiteral==null) {
-                    nextLiteral = templateTokens.get(0);
-                    if (qstInput.isQuoted(nextLiteral)) {
-                        nextLiteral = qstInput.unwrapIfQuoted(nextLiteral);
-                        isLiteralExpected = true;
-                    } else {
-                        isLiteralExpected = false;
-                    }
-                } else {
-                    isLiteralExpected = true;
-                }
-                if (isLiteralExpected) {
-                    int nli = v.indexOf(nextLiteral);
-                    if (nli>0) {
-                        // literal found in unquoted string, eg "foo=bar" when literal is =
-                        value = v.substring(0, nli);
-                        inputRemaining = inputRemaining.substring(value.length());
-                    } else {
-                        // literal not found
-                        value = v;
-                        inputRemaining = inputRemaining.substring(value.length());
-                    }
-                } else {
-                    // next is not a literal, so the whole token is the value
-                    value = v;
-                    inputRemaining = inputRemaining.substring(value.length());
-                }
-            }
-            return value;
-        }
-    }
-
-
 }
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessorEpToQst.java b/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessorEpToQst.java
new file mode 100644
index 0000000..cb94600
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessorEpToQst.java
@@ -0,0 +1,513 @@
+/*
+ * 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.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+
+import org.apache.brooklyn.core.workflow.utils.ExpressionParser;
+import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNode;
+import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNodeOrValue;
+import org.apache.brooklyn.util.collections.CollectionMerger;
+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.QuotedStringTokenizer;
+import org.apache.brooklyn.util.text.Strings;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is the latest version of the SP which uses the EP for input, and the QST for templates,
+ * to keep backwards compatibility but allow better handling for internal double quotes and interpolated expressions with spaces.
+ * It falls back to QST when there are errors to maximize compatibility.
+ *
+ * ===
+ *
+ * Accepts a shorthand template, and converts it to a map of values,
+ * e.g. given template "[ ?${type_set} ${sensor.type} ] ${sensor.name} \"=\" ${value}"
+ * and input "integer foo=3", this will return
+ * { sensor: { type: integer, name: foo }, value: 3, type_set: true }.
+ *
+ * Expects space-separated TOKEN where TOKEN is either:
+ *
+ * ${VAR} - to set VAR, which should be of the regex [A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*, with dot separation used to set nested maps;
+ *   will match a quoted string if supplied, else up to the next literal if the next token is a literal, else the next work.
+ * ${VAR...} - as above, but will collect multiple args if needed (if the next token is a literal matched further on, or if at end of word)
+ * "LITERAL" - to expect a literal expression. this must include the quotation marks and should include spaces if spaces are required.
+ * [ TOKEN ] - to indicate TOKEN is optional, where TOKEN is one of the above sections. parsing is attempted first with it, then without it.
+ * [ ?${VAR} TOKEN ] - as `[ TOKEN ]` but VAR is set true or false depending whether this optional section was matched.
+ *
+ * Would be nice to support A | B (exclusive or) for A or B but not both (where A might contain a literal for disambiguation),
+ * and ( X ) for X required but grouped (for use with | (exclusive or) where one option is required).
+ * Would also be nice to support any order, which could be ( A & B ) to allow A B or B A.
+ *
+ * But for now we've made do without it, with some compromises:
+ * * keywords must follow the order indicated
+ * * exclusive alternatives are disallowed by code subsequently or checked separately (eg Transform)
+ */
+public class ShorthandProcessorEpToQst {
+
+    private static final Logger log = LoggerFactory.getLogger(ShorthandProcessorEpToQst.class);
+
+    static final boolean TRY_HARDER_FOR_QST_COMPATIBILITY = true;
+
+    private final String template;
+    boolean finalMatchRaw = false;
+    boolean failOnMismatch = true;
+
+    public ShorthandProcessorEpToQst(String template) {
+        this.template = template;
+    }
+
+    public Maybe<Map<String,Object>> process(String input) {
+        return new ShorthandProcessorQstAttempt(this, input).call();
+    }
+
+    /** whether the last match should preserve quotes and spaces; default false */
+    public ShorthandProcessorEpToQst withFinalMatchRaw(boolean finalMatchRaw) {
+        this.finalMatchRaw = finalMatchRaw;
+        return this;
+    }
+
+    /** whether to fail on mismatched quotes in the input, default true */
+    public ShorthandProcessorEpToQst withFailOnMismatch(boolean failOnMismatch) {
+        this.failOnMismatch = failOnMismatch;
+        return this;
+    }
+
+    static class ShorthandProcessorQstAttempt {
+        private final List<String> templateTokens;
+        private final String inputOriginal;
+        private final QuotedStringTokenizer qst0;
+        private final String template;
+        private final ShorthandProcessorEpToQst options;
+        int optionalDepth = 0;
+        int optionalSkippingInput = 0;
+        private String inputRemaining;
+        Map<String, Object> result;
+        Consumer<String> valueUpdater;
+
+        ShorthandProcessorQstAttempt(ShorthandProcessorEpToQst proc, String input) {
+            this.template = proc.template;
+            this.options = proc;
+            this.qst0 = qst0(template);
+            this.templateTokens = qst0.remainderAsList();  // QST works fine for the template
+            this.inputOriginal = input;
+        }
+
+        private QuotedStringTokenizer qst0(String x) {
+            return QuotedStringTokenizer.builder().includeQuotes(true).includeDelimiters(false).expectQuotesDelimited(true).failOnOpenQuote(options.failOnMismatch).build(x);
+        }
+
+        public synchronized Maybe<Map<String,Object>> call() {
+            if (result == null) {
+                result = MutableMap.of();
+                inputRemaining = inputOriginal;
+            } else {
+                throw new IllegalStateException("Only allowed to use once");
+            }
+            Maybe<Object> error = doCall();
+            if (error.isAbsent()) return Maybe.Absent.castAbsent(error);
+            inputRemaining = Strings.trimStart(inputRemaining);
+            if (Strings.isNonBlank(inputRemaining)) {
+                if (valueUpdater!=null) {
+                    //QuotedStringTokenizer qstInput = qst(inputRemaining);
+                    valueUpdater.accept(getRemainderPossiblyRaw(inputRemaining));
+                } else {
+                    // shouldn't come here
+                    return Maybe.absent("Input has trailing characters after template is matched: '" + inputRemaining + "'");
+                }
+            }
+            return Maybe.of(result);
+        }
+
+        protected Maybe<Object> doCall() {
+            boolean isEndOfOptional = false;
+            outer: while (true) {
+                if (isEndOfOptional) {
+                    if (optionalDepth <= 0) {
+                        throw new IllegalStateException("Unexpected optional block closure");
+                    }
+                    optionalDepth--;
+                    if (optionalSkippingInput>0) {
+                        // we were in a block where we skipped something optional because it couldn't be matched; outer parser is now canonical,
+                        // and should stop skipping
+                        return Maybe.of(true);
+                    }
+                    isEndOfOptional = false;
+                }
+
+                if (templateTokens.isEmpty()) {
+                    if (Strings.isNonBlank(inputRemaining) && valueUpdater==null) {
+                        return Maybe.absent("Input has trailing characters after template is matched: '" + inputRemaining + "'");
+                    }
+                    if (optionalDepth>0)
+                        return Maybe.absent("Mismatched optional marker in template");
+                    return Maybe.of(true);
+                }
+                String t = templateTokens.remove(0);
+
+                if (t.startsWith("[")) {
+                    t = t.substring(1);
+                    if (!t.isEmpty()) {
+                        templateTokens.add(0, t);
+                    }
+                    String optionalPresentVar = null;
+                    if (!templateTokens.isEmpty() && templateTokens.get(0).startsWith("?")) {
+                        String v = templateTokens.remove(0);
+                        if (v.startsWith("?${") && v.endsWith("}")) {
+                            optionalPresentVar = v.substring(3, v.length() - 1);
+                        } else {
+                            throw new IllegalStateException("? after [ should indicate optional presence variable using syntax '?${var}', not '"+v+"'");
+                        }
+                    }
+                    Maybe<Object> cr;
+                    if (optionalSkippingInput<=0) {
+                        // make a deep copy so that valueUpdater writes get replayed
+                        Map<String, Object> backupResult = (Map) CollectionMerger.builder().deep(true).build().merge(MutableMap.of(), result);
+                        Consumer<String> backupValueUpdater = valueUpdater;
+                        String backupInputRemaining = inputRemaining;
+                        List<String> backupTemplateTokens = MutableList.copyOf(templateTokens);
+                        int oldDepth = optionalDepth;
+                        int oldSkippingDepth = optionalSkippingInput;
+
+                        optionalDepth++;
+                        cr = doCall();
+                        if (cr.isPresent()) {
+                            // succeeded
+                            if (optionalPresentVar!=null) result.put(optionalPresentVar, true);
+                            continue;
+
+                        } else {
+                            // restore
+                            result = backupResult;
+                            valueUpdater = backupValueUpdater;
+                            if (optionalPresentVar!=null) result.put(optionalPresentVar, false);
+                            inputRemaining = backupInputRemaining;
+                            templateTokens.clear();
+                            templateTokens.addAll(backupTemplateTokens);
+                            optionalDepth = oldDepth;
+                            optionalSkippingInput = oldSkippingDepth;
+
+                            optionalSkippingInput++;
+                            optionalDepth++;
+                            cr = doCall();
+                            if (cr.isPresent()) {
+                                optionalSkippingInput--;
+                                continue;
+                            }
+                        }
+                    } else {
+                        if (optionalPresentVar!=null) {
+                            result.put(optionalPresentVar, false);
+                            valueUpdater = null;
+                        }
+                        optionalDepth++;
+                        cr = doCall();
+                        if (cr.isPresent()) {
+                            continue;
+                        }
+                    }
+                    return cr;
+                }
+
+                isEndOfOptional = t.endsWith("]");
+
+                if (isEndOfOptional) {
+                    t = t.substring(0, t.length() - 1);
+                    if (t.isEmpty()) continue;
+                    // next loop will process the end of the optionality
+                }
+
+                if (qst0.isQuoted(t)) {
+                    if (optionalSkippingInput>0) continue;
+
+                    String literal = qst0.unwrapIfQuoted(t);
+                    do {
+                        // ignore leading spaces (since the quoted string tokenizer will have done that anyway); but their _absence_ can be significant for intra-token searching when matching a var
+                        inputRemaining = Strings.trimStart(inputRemaining);
+                        if (inputRemaining.startsWith(Strings.trimStart(literal))) {
+                            // literal found
+                            inputRemaining = inputRemaining.substring(Strings.trimStart(literal).length());
+                            continue outer;
+                        }
+                        if (inputRemaining.isEmpty()) return Maybe.absent("Literal '"+literal+"' expected, when end of input reached");
+                        if (valueUpdater!=null) {
+                            if (inputRemaining.isEmpty()) return Maybe.absent("Literal '"+literal+"' expected, when end of input tokens reached");
+                            String value = getNextInputTokenUpToPossibleExpectedLiteral(literal);
+                            valueUpdater.accept(value);
+                            continue;
+                        }
+                        return Maybe.absent("Literal '"+literal+"' expected, when encountered '"+inputRemaining+"'");
+                    } while (true);
+                }
+
+                if (t.startsWith("${") && t.endsWith("}")) {
+                    if (optionalSkippingInput>0) continue;
+
+                    t = t.substring(2, t.length()-1);
+                    String value;
+
+                    inputRemaining = inputRemaining.trim();
+                    if (inputRemaining.isEmpty()) return Maybe.absent("End of input when looking for variable "+t);
+
+                    if (!templateTokens.stream().filter(x -> !x.equals("]")).findFirst().isPresent()) {
+                        // last word (whether optional or not) takes everything
+                        value = getRemainderPossiblyRaw(inputRemaining);
+                        inputRemaining = "";
+
+                    } else {
+                        value = getNextInputTokenUpToPossibleExpectedLiteral(null);
+                    }
+                    boolean multiMatch = t.endsWith("...");
+                    if (multiMatch) t = Strings.removeFromEnd(t, "...");
+                    String keys[] = t.split("\\.");
+                    final String tt = t;
+                    valueUpdater = v2 -> {
+                        Map target = result;
+                        for (int i=0; i<keys.length; i++) {
+                            if (!Pattern.compile("[A-Za-z0-9_-]+").matcher(keys[i]).matches()) {
+                                throw new IllegalArgumentException("Invalid variable '"+tt+"'");
+                            }
+                            if (i == keys.length - 1) {
+                                target.compute(keys[i], (k, v) -> v == null ? v2 : v + " " + v2);
+                            } else {
+                                // need to make sure we have a map or null
+                                target = (Map) target.compute(keys[i], (k, v) -> {
+                                    if (v == null) return MutableMap.of();
+                                    if (v instanceof Map) return v;
+                                    return Maybe.absent("Cannot process shorthand for " + Arrays.asList(keys) + " because entry '" + k + "' is not a map (" + v + ")");
+                                });
+                            }
+                        }
+                    };
+                    valueUpdater.accept(value);
+                    if (!multiMatch) valueUpdater = null;
+                    continue;
+                }
+
+                // unexpected token
+                return Maybe.absent("Unexpected token in shorthand pattern '"+template+"' at position "+(template.lastIndexOf(t)+1));
+            }
+        }
+
+        private String getRemainderPossiblyRawQst(QuotedStringTokenizer qstInput) {
+            String value;
+            value = Strings.join(qstInput.remainderRaw(), "");
+            if (!options.finalMatchRaw) {
+                return qstInput.unwrapIfQuoted(value);
+            }
+            return value;
+        }
+
+        private String getRemainderPossiblyRaw(String inputRemaining) {
+            Maybe<String> mp = getRemainderPossiblyRawEp(inputRemaining);
+
+            if (TRY_HARDER_FOR_QST_COMPATIBILITY || (mp.isAbsent() && !options.failOnMismatch)) {
+                String qstResult = getRemainderPossiblyRawQst(qst0(inputRemaining));
+                if (mp.isPresent() && !mp.get().equals(qstResult)) {
+                    log.debug("Shorthand parsing semantics change for: "+inputOriginal+"\n" +
+                            "  old qst: "+qstResult+"\n"+
+                            "  new exp: "+mp.get());
+                    // to debug
+//                    getRemainderPossiblyRawEp(inputRemaining);
+                }
+                if (mp.isAbsent()) {
+                    return qstResult;
+                }
+            }
+
+            return mp.get();
+        }
+        private Maybe<String> getRemainderPossiblyRawEp(String inputRemaining) {
+            if (options.finalMatchRaw) {
+                return Maybe.of(inputRemaining);
+            }
+            Maybe<List<ParseNodeOrValue>> mp = ShorthandProcessorExprParser.tokenizer().parseEverything(inputRemaining);
+            return mp.map(pnl -> {
+                final boolean UNQUOTE_INDIVIDUAL_WORDS = false;  // legacy behaviour
+
+                if (pnl.size()==1 || UNQUOTE_INDIVIDUAL_WORDS) {
+                    return ExpressionParser.getAllUnquoted(pnl);
+                } else {
+                    return ExpressionParser.getUnescapedButNotUnquoted(pnl);
+                }
+            });
+        }
+
+        private String getNextInputTokenUpToPossibleExpectedLiteralQst(QuotedStringTokenizer qstInput, String nextLiteral) {
+            String value;
+            if (!qstInput.hasMoreTokens()) {
+                return "";  // shouldn't happen
+            }
+            String v = qstInput.nextToken();
+            if (qstInput.isQuoted(v)) {
+                // input was quoted, eg "\"foo=b\" ..." -- ignore the = in "foo=b"
+                value = qstInput.unwrapIfQuoted(v);
+                inputRemaining = inputRemaining.substring(v.length());
+            } else {
+                // input not quoted, if next template token is literal, look for it
+                boolean isLiteralExpected;
+                if (nextLiteral==null) {
+                    nextLiteral = templateTokens.get(0);
+                    if (qstInput.isQuoted(nextLiteral)) {
+                        nextLiteral = qstInput.unwrapIfQuoted(nextLiteral);
+                        isLiteralExpected = true;
+                    } else {
+                        isLiteralExpected = false;
+                    }
+                } else {
+                    isLiteralExpected = true;
+                }
+                if (isLiteralExpected) {
+                    int nli = v.indexOf(nextLiteral);
+                    if (nli>0) {
+                        // literal found in unquoted string, eg "foo=bar" when literal is =
+                        value = v.substring(0, nli);
+                        inputRemaining = inputRemaining.substring(value.length());
+                    } else {
+                        // literal not found
+                        value = v;
+                        inputRemaining = inputRemaining.substring(value.length());
+                    }
+                } else {
+                    // next is not a literal, so the whole token is the value
+                    value = v;
+                    inputRemaining = inputRemaining.substring(value.length());
+                }
+            }
+            return value;
+        }
+
+        private String getNextInputTokenUpToPossibleExpectedLiteral(String nextLiteral) {
+            String oi = inputRemaining;
+            Maybe<String> v1 = getNextInputTokenUpToPossibleExpectedLiteralEp(nextLiteral);
+
+            if (TRY_HARDER_FOR_QST_COMPATIBILITY || (v1.isAbsent() && !options.failOnMismatch)) {
+                String ni = inputRemaining;
+
+                inputRemaining = oi;
+                String qstResult = getNextInputTokenUpToPossibleExpectedLiteralQst(qst0(inputRemaining), nextLiteral);
+                if (v1.isPresent() && !v1.get().equals(qstResult)) {
+                    log.debug("Shorthand parsing semantics change for literal " + nextLiteral + ": " + inputOriginal + "\n" +
+                            "  old qst: " + qstResult + "\n" +
+                            "  new exp: " + v1.get());
+
+//                    // to debug differences
+//                    inputRemaining = oi;
+////                    getNextInputTokenUpToPossibleExpectedLiteralQst(qst0(inputRemaining), nextLiteral);
+//                    getNextInputTokenUpToPossibleExpectedLiteralEp(nextLiteral);
+                }
+                if (v1.isAbsent()) {
+                    return qstResult;
+                }
+                inputRemaining = ni;
+            }
+
+            return v1.get();
+        }
+
+        private Maybe<String> getNextInputTokenUpToPossibleExpectedLiteralEp(String nextLiteral) {
+            String result = "";
+            boolean canRepeat = true;
+
+            Maybe<ParseNode> parseM = ShorthandProcessorExprParser.tokenizer().parse(inputRemaining);
+            while (canRepeat) {
+                String value;
+                if (parseM.isAbsent()) return Maybe.castAbsent(parseM);
+
+                List<ParseNodeOrValue> tokens = parseM.get().getContents();
+                ParseNodeOrValue t = tokens.iterator().next();
+
+                if (ExpressionParser.isQuotedExpressionNode(t)) {
+                    // input was quoted, eg "\"foo=b\" ..." -- ignore the = in "foo=b"
+                    value = ExpressionParser.getUnquoted(t);
+                    inputRemaining = inputRemaining.substring(t.getSource().length());
+
+                } else {
+                    // input not quoted, if next template token is literal, look for it
+                    boolean isLiteralExpected;
+                    if (nextLiteral == null) {
+                        String nl = templateTokens.get(0);
+                        if (qst0.isQuoted(nl)) {
+                            nextLiteral = qst0.unwrapIfQuoted(nl);
+                            isLiteralExpected = true;
+                        } else {
+                            isLiteralExpected = false;
+                        }
+                    } else {
+                        isLiteralExpected = true;
+                    }
+                    if (isLiteralExpected) {
+                        int nli =
+                                // previously took next QST token
+                                qst0(inputRemaining).nextToken().indexOf(nextLiteral);
+
+//                            // this is simple, slightly greedier;
+//                            // slightly too greedy, in that nextLiteral inside quotes further away will match
+//                            // and it breaks backwards compatibility
+//                            inputRemaining.indexOf(nextLiteral);
+
+//                            // this parse node is probably too short
+//                            t.getSource().indexOf(nextLiteral);
+
+                        final boolean ALLOW_NOTHING_BEFORE_LITERAL = false;
+                        if ((nli == 0 && ALLOW_NOTHING_BEFORE_LITERAL) || nli > 0) {
+                            // literal found in unquoted string, eg "foo=bar" when literal is =
+                            if (!qst0(inputRemaining).nextToken().startsWith(inputRemaining.substring(0, nli))) {
+                                String v = qst0(inputRemaining).nextToken();
+                            }
+                            value = inputRemaining.substring(0, nli);
+                            inputRemaining = inputRemaining.substring(nli);
+                            canRepeat = false;
+
+                        } else {
+                            // literal not found
+                            value = t.getSource();  // since we know it isn't quoted
+                            inputRemaining = inputRemaining.substring(value.length());
+                        }
+                    } else {
+                        // next is not a literal, so the whole token is the value - not unquoted
+                        value = t.getSource();
+                        inputRemaining = inputRemaining.substring(value.length());
+                    }
+                }
+                result += value;
+
+                canRepeat &= !inputRemaining.isEmpty();
+                if (canRepeat) {
+                    parseM = ShorthandProcessorExprParser.tokenizer().parse(inputRemaining);
+                    if (parseM.isAbsent()) canRepeat = false;
+                    else {
+                        if (ExpressionParser.startsWithWhitespace(parseM.get())) canRepeat = false;
+                        // otherwise, to act like QST, we treat non-whitespace-separated things all as one token
+                    }
+                }
+            }
+
+            return Maybe.of(result);
+        }
+
+    }
+
+}
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessorExprParser.java b/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessorExprParser.java
new file mode 100644
index 0000000..ae76e31
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessorExprParser.java
@@ -0,0 +1,463 @@
+/*
+ * 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.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.apache.brooklyn.core.workflow.utils.ExpressionParser;
+import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.CharactersCollectingParseMode;
+import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNode;
+import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNodeOrValue;
+import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseValue;
+import org.apache.brooklyn.util.collections.CollectionMerger;
+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 org.apache.commons.lang3.tuple.Pair;
+
+/**
+ * An implentation of {@link ShorthandProcessor} which tries to use the ExpressionParser for everything.
+ * However the semantics of a parse tree vs a linear string are too different so this is deprecated.
+ *
+ * It would be better to write this anew, and accept a breakage of semantics -- or use SPEpToQst (which we do)
+ */
+@Deprecated
+public class ShorthandProcessorExprParser {
+
+    private final String template;
+    boolean finalMatchRaw = false;
+
+    public ShorthandProcessorExprParser(String template) {
+        this.template = template;
+    }
+
+    public Maybe<Map<String,Object>> process(String input) {
+        return new ShorthandProcessorAttempt(this, input).call();
+    }
+
+    /** whether the last match should preserve quotes and spaces; default false */
+    public ShorthandProcessorExprParser withFinalMatchRaw(boolean finalMatchRaw) {
+        this.finalMatchRaw = finalMatchRaw;
+        return this;
+    }
+
+    public static ExpressionParser tokenizer() {
+        return ExpressionParser
+                .newDefaultAllowingUnquotedAndSplittingOnWhitespace()
+                .includeGroupingBracketsAtUsualPlaces()
+
+                // including = means we can handle let x=1 !
+                .includeAllowedTopLevelTransition(new CharactersCollectingParseMode("equals", '='))
+
+                // whitespace in square brackets might be interesting -- though i don't think it is
+//                .includeAllowedSubmodeTransition(ExpressionParser.SQUARE_BRACKET, ExpressionParser.WHITESPACE)
+                ;
+    }
+    private static Maybe<List<ParseNodeOrValue>> tokenized(String x) {
+        return tokenizer().parseEverything(x);
+    }
+
+    static class ShorthandProcessorAttempt {
+        private final List<ParseNodeOrValue> templateTokensOriginal;
+        private final String inputOriginal2;
+        private final String template;
+        private final ShorthandProcessorExprParser options;
+        int optionalDepth = 0;
+        int optionalSkippingInput = 0;
+        Map<String, Object> result;
+        Consumer<String> valueUpdater;
+
+        ShorthandProcessorAttempt(ShorthandProcessorExprParser proc, String input) {
+            this.template = proc.template;
+            this.options = proc;
+            this.templateTokensOriginal = tokenized(template).get();
+            this.inputOriginal2 = input;
+        }
+
+        public synchronized Maybe<Map<String,Object>> call() {
+            if (result != null) {
+                throw new IllegalStateException("Only allowed to use once");
+            }
+            result = MutableMap.of();
+            List<ParseNodeOrValue> templateTokens = MutableList.copyOf(templateTokensOriginal);
+            List<ParseNodeOrValue> inputTokens = tokenized(inputOriginal2).get();
+            Maybe<Boolean> error = doCall(templateTokens, inputTokens, 0);
+            if (error.isAbsent()) return Maybe.Absent.castAbsent(error);
+            if (!error.get()) return Maybe.absent("Template could not be matched."); // shouldn't happen
+            chompWhitespace(inputTokens);
+            if (!inputTokens.isEmpty()) {
+                if (valueUpdater!=null) {
+                    valueUpdater.accept(getRemainderPossiblyRaw(inputTokens));
+                } else {
+                    // shouldn't come here
+                    return Maybe.absent("Input has trailing characters after template is matched: '" + inputTokens.stream().map(x -> x.getSource()).collect(Collectors.joining()) + "'");
+                }
+            }
+            return Maybe.of(result);
+        }
+
+        private static final Object EMPTY = "empty";
+
+        protected Maybe<Boolean> doCall(List<ParseNodeOrValue> templateTokens, List<ParseNodeOrValue> inputTokens, int depth) {
+//            boolean isEndOfOptional = false;
+            outer: while (true) {
+//                if (isEndOfOptional) {
+//                    if (optionalDepth <= 0) {
+//                        throw new IllegalStateException("Unexpected optional block closure");
+//                    }
+//                    optionalDepth--;
+//                    if (optionalSkippingInput>0) {
+//                        // we were in a block where we skipped something optional because it couldn't be matched; outer parser is now canonical,
+//                        // and should stop skipping
+//                        return Maybe.of(true);
+//                    }
+//                    isEndOfOptional = false;
+//                }
+
+                if (templateTokens.isEmpty()) {
+////                    if (Strings.isNonBlank(inputRemaining) && valueUpdater==null) {
+////                        return Maybe.absent("Input has trailing characters after template is matched: '" + inputRemaining + "'");
+////                    }
+//                    if (optionalDepth>0)
+//                        return Maybe.absent("Mismatched optional marker in template");
+                    return Maybe.of(true);
+                }
+                ParseNodeOrValue tnv = templateTokens.remove(0);
+
+                if (tnv.isParseNodeMode(ExpressionParser.SQUARE_BRACKET)) {
+                    List<ParseNodeOrValue> tt = ((ParseNode) tnv).getContents();
+                    String optionalPresentVar = null;
+                    chompWhitespace(tt);
+                    if (!tt.isEmpty() && tt.get(0).getParseNodeMode().equals(ParseValue.MODE) &&
+                            ((String)tt.get(0).getContents()).startsWith("?")) {
+                        ParseNodeOrValue vt = tt.remove(0);
+                        ParseNodeOrValue vt2 = tt.stream().findFirst().orElse(null);
+                        if (vt2!=null && vt2.isParseNodeMode(ExpressionParser.INTERPOLATED)) {
+                            ParseValue vt2c = ((ParseNode) vt2).getOnlyContent()
+                                    .mapMaybe(x -> x instanceof ParseValue ? Maybe.of((ParseValue)x) : Maybe.absent())
+                                    .orThrow(() -> new IllegalStateException("? after [ should be followed by optional presence variable using syntax '?${var}', not '" + vt2.getSource() + "'"));
+                            optionalPresentVar = vt2c.getContents();
+                            tt.remove(0);
+                        } else {
+                            throw new IllegalStateException("? after [ should indicate optional presence variable using syntax '?${var}', not '"+vt.getSource()+"'");
+                        }
+                    }
+                    Maybe<Boolean> cr;
+                    if (optionalSkippingInput<=0) {
+                        // make a deep copy so that valueUpdater writes get replayed
+                        Map<String, Object> backupResult = (Map) CollectionMerger.builder().deep(true).build().merge(MutableMap.of(), result);
+                        Consumer<String> backupValueUpdater = valueUpdater;
+                        List<ParseNodeOrValue> backupInputRemaining = MutableList.copyOf(inputTokens);
+                        List<ParseNodeOrValue> backupTT = tt;
+                        int oldDepth = optionalDepth;
+                        int oldSkippingDepth = optionalSkippingInput;
+
+                        optionalDepth++;
+                        tt = MutableList.copyOf(tt);
+                        tt.addAll(templateTokens);
+                        // this nested call handles the bracketed nodes, and everything after, to make sure the inclusion of the optional works to the end
+                        cr = doCall(tt, inputTokens, depth+1);
+                        if (cr.isPresent()) {
+                            if (cr.get()) {
+                                // succeeded
+                                templateTokens.clear();  // because the subcall handled them
+                                if (optionalPresentVar != null) result.put(optionalPresentVar, true);
+                                continue;
+                            } else {
+                                // go into next block, we'll re-run, but with optional skipping turned on
+                            }
+                        }
+                        // restore
+                        result = backupResult;
+                        valueUpdater = backupValueUpdater;
+                        if (optionalPresentVar!=null) result.put(optionalPresentVar, false);
+                        inputTokens.clear();
+                        inputTokens.addAll(backupInputRemaining);
+                        tt = backupTT;
+                        optionalDepth = oldDepth;
+                        optionalSkippingInput = oldSkippingDepth;
+
+                        optionalSkippingInput++;
+                        optionalDepth++;
+//                        tt.add(0, tnv); // put our bracket back on the head so we come back in to this block
+                        cr = doCall(tt, inputTokens, depth+1);
+                        if (cr.isPresent()) {
+                            optionalSkippingInput--;
+                            continue;
+                        }
+                    } else {
+                        if (optionalPresentVar!=null) {
+                            result.put(optionalPresentVar, false);
+                            valueUpdater = null;
+                        }
+                        optionalDepth++;
+                        cr = doCall(tt, inputTokens, depth+1);
+                        if (cr.isPresent()) {
+                            continue;
+                        }
+                    }
+                    // failed
+                    return cr;
+                }
+
+                if (ExpressionParser.isQuotedExpressionNode(tnv)) {
+                    if (optionalSkippingInput>0) continue;
+
+                    Maybe<String> literalM = getSingleStringContents(tnv, "inside shorthand template quote");
+                    if (literalM.isAbsent()) return Maybe.castAbsent(literalM);
+                    String literal = Strings.trimStart(literalM.get());
+//                    boolean foundSomething = false;
+                    valueUpdater: do {
+                        chompWhitespace(inputTokens);
+                        literal: while (!inputTokens.isEmpty()) {
+                            if (!literal.isEmpty()) {
+                                Maybe<String> nextInputM = getSingleStringContents(inputTokens.stream().findFirst().orElse(null), "matching literal '" + literal + "'");
+                                String nextInput = Strings.trimStart(nextInputM.orNull());
+                                if (nextInput == null) {
+                                    break; // shouldn't happen, but if so, fall through to error below
+                                }
+                                if (literal.startsWith(nextInput)) {
+                                    literal = Strings.removeFromStart(literal, nextInput.trim());
+                                    inputTokens.remove(0);
+                                    chompWhitespace(inputTokens);
+                                    literal = Strings.trimStart(literal);
+                                    continue literal;
+                                }
+                                if (nextInput.startsWith(literal)) {
+                                    String putBackOnInput = nextInput.substring(literal.length());
+                                    literal = "";
+                                    inputTokens.remove(0);
+                                    if (!putBackOnInput.isEmpty()) inputTokens.add(0, new ParseValue(putBackOnInput));
+                                    else chompWhitespace(inputTokens);
+                                    // go into next block
+                                }
+                            }
+                            if (literal.isEmpty()) {
+                                // literal found
+                                continue outer;
+                            }
+                            break;
+                        }
+                        if (inputTokens.isEmpty()) {
+                            return Maybe.absent("Literal '" + literalM.get() + "' expected, when end of input reached");
+                        }
+                        if (valueUpdater!=null) {
+                            if (inputTokens.isEmpty()) return Maybe.absent("Literal '"+literal+"' expected, when end of input tokens reached");
+                            Pair<String,Boolean> value = getNextInputTokenUpToPossibleExpectedLiteral(inputTokens, templateTokens, literal, false);
+                            if (value.getRight()) valueUpdater.accept(value.getLeft());
+                            // always continue, until we see the literal
+//                            if (!value.getRight()) {
+//                                return Maybe.of(foundSomething);
+//                            }
+//                            foundSomething = true;
+                            continue valueUpdater;
+                        }
+                        return Maybe.absent("Literal '"+literal+"' expected, when encountered '"+inputTokens+"'");
+                    } while (true);
+                }
+
+                if (tnv.isParseNodeMode(ExpressionParser.INTERPOLATED)) {
+                    if (optionalSkippingInput>0) continue;
+
+                    Maybe<String> varNameM = getSingleStringContents(tnv, "in template interpolated variable definition");
+                    if (varNameM.isAbsent()) return Maybe.castAbsent(varNameM);
+                    String varName = varNameM.get();
+
+                    if (inputTokens.isEmpty()) return Maybe.absent("End of input when looking for variable "+varName);
+
+                    chompWhitespace(inputTokens);
+                    String value;
+                    if (templateTokens.isEmpty()) {
+                        // last word (whether optional or not) takes everything
+                        value = getRemainderPossiblyRaw(inputTokens);
+                        inputTokens.clear();
+
+                    } else {
+                        Pair<String,Boolean> valueM = getNextInputTokenUpToPossibleExpectedLiteral(inputTokens, templateTokens, null, false);
+                        if (!valueM.getRight()) {
+                            // if we didn't find an expression, bail out
+                            return Maybe.absent("Did not find expression prior to expected literal");
+                        }
+                        value = valueM.getLeft();
+                    }
+                    boolean dotsMultipleWordMatch = varName.endsWith("...");
+                    if (dotsMultipleWordMatch) varName = Strings.removeFromEnd(varName, "...");
+                    String keys[] = varName.split("\\.");
+                    final String tt = varName;
+                    valueUpdater = v2 -> {
+                        Map target = result;
+                        for (int i=0; i<keys.length; i++) {
+                            if (!Pattern.compile("[A-Za-z0-9_-]+").matcher(keys[i]).matches()) {
+                                throw new IllegalArgumentException("Invalid variable '"+tt+"'");
+                            }
+                            if (i == keys.length - 1) {
+                                target.compute(keys[i], (k, v) -> v == null ? v2 : v + " " + v2);
+                            } else {
+                                // need to make sure we have a map or null
+                                target = (Map) target.compute(keys[i], (k, v) -> {
+                                    if (v == null) return MutableMap.of();
+                                    if (v instanceof Map) return v;
+                                    return Maybe.absent("Cannot process shorthand for " + Arrays.asList(keys) + " because entry '" + k + "' is not a map (" + v + ")");
+                                });
+                            }
+                        }
+                    };
+                    valueUpdater.accept(value);
+                    if (!dotsMultipleWordMatch && !templateTokens.isEmpty()) valueUpdater = null;
+                    continue;
+                }
+
+                if (tnv.isParseNodeMode(ExpressionParser.WHITESPACE)) {
+                    chompWhitespace(templateTokens);
+                    continue;
+                }
+
+                // unexpected token
+                return Maybe.absent("Unexpected token in shorthand pattern '"+template+"' at "+tnv+", followed by "+templateTokens);
+            }
+        }
+
+        private static Maybe<String> getSingleStringContents(ParseNodeOrValue tnv, String where) {
+            if (tnv==null)
+                Maybe.absent(()-> new IllegalArgumentException("No remaining tokens "+where));
+            if (tnv instanceof ParseValue) return Maybe.of(((ParseValue)tnv).getContents());
+            Maybe<ParseNodeOrValue> c = ((ParseNode) tnv).getOnlyContent();
+            if (c.isAbsent()) Maybe.absent(()-> new IllegalArgumentException("Expected single string "+where));
+            return getSingleStringContents(c.get(), where);
+        }
+
+        private static String getEscapedForInsideQuotedString(ParseNodeOrValue tnv, String where) {
+            if (tnv==null) throw new IllegalArgumentException("No remaining tokens "+where);
+            if (tnv instanceof ParseValue) return ((ParseValue)tnv).getContents();
+            if (ExpressionParser.isQuotedExpressionNode(tnv))
+                return ((ParseNode) tnv).getContents().stream().map(x -> getEscapedForInsideQuotedString(x, where)).collect(Collectors.joining());
+            else
+                return ((ParseNode) tnv).getContents().stream().map(x -> x.getSource()).collect(Collectors.joining());
+        }
+
+        private void chompWhitespace(List<ParseNodeOrValue> tt) {
+            while (!tt.isEmpty() && tt.get(0).isParseNodeMode(ExpressionParser.WHITESPACE)) tt.remove(0);
+        }
+
+        private String getRemainderPossiblyRaw(List<ParseNodeOrValue> remainder) {
+            if (options.finalMatchRaw) {
+                return remainder.stream().map(ParseNodeOrValue::getSource).collect(Collectors.joining());
+            } else {
+                return remainder.stream().map(x -> {
+                    if (x instanceof ParseValue) return ((ParseValue)x).getContents();
+                    return getRemainderPossiblyRaw(((ParseNode)x).getContents());
+                }).collect(Collectors.joining());
+            }
+        }
+
+        private Pair<String,Boolean> getNextInputTokenUpToPossibleExpectedLiteral(List<ParseNodeOrValue> inputTokens, List<ParseNodeOrValue> templateTokens, String nextLiteral, boolean removeLiteralFromInput) {
+            String value;
+            ParseNodeOrValue nextInput = inputTokens.iterator().next();
+            Boolean foundLiteral;
+            Boolean foundContentsPriorToLiteral;
+            if (ExpressionParser.isQuotedExpressionNode(nextInput)) {
+                // quoted expressions in input won't match template literals
+                value = getEscapedForInsideQuotedString(nextInput, "reading quoted input");
+                inputTokens.remove(0);
+                foundLiteral = false;
+                foundContentsPriorToLiteral = true;
+            } else {
+                boolean isLiteralExpected;
+                if (nextLiteral==null) {
+                    // input not quoted, no literal supplied; if next template token is literal, look for it
+                    chompWhitespace(templateTokens);
+                    ParseNodeOrValue nextTT = templateTokens.isEmpty() ? null : templateTokens.get(0);
+                    if (ExpressionParser.isQuotedExpressionNode(nextTT)) {
+                        Maybe<String> nextLiteralM = getSingleStringContents(nextTT, "identifying expected literal");
+                        isLiteralExpected = nextLiteralM.isPresent();
+                        nextLiteral = nextLiteralM.orNull();
+                    } else {
+                        isLiteralExpected = false;
+                    }
+                } else {
+                    isLiteralExpected = true;
+                }
+                if (isLiteralExpected) {
+                    value = "";
+                    while (true) {
+                        Maybe<String> vsm = getSingleStringContents(nextInput, "looking for literal '" + nextLiteral + "'");
+                        if (vsm.isAbsent()) {
+                            foundLiteral = false;
+                            break;
+                        }
+                        String vs = vsm.get();
+                        int nli = (" "+vs+" ").indexOf(nextLiteral); // wrap the token we got in spaces in case there is a quoted space in the next literal
+                        if (nli >= 0) {
+                            // literal found in unquoted string, eg "foo=bar" when literal is =
+                            if (nli>0) nli--; // walk back the space
+                            value += vs.substring(0, nli);
+                            value = Strings.trimEnd(value);
+                            if (removeLiteralFromInput) nli += nextLiteral.length();
+                            String putBackOnInput = vs.substring(nli);
+                            inputTokens.remove(0);
+                            if (!putBackOnInput.isEmpty()) inputTokens.add(0, new ParseValue(putBackOnInput));
+                            foundLiteral = true;
+                            break;
+                        } else {
+                            nli = (" "+value + vs).indexOf(nextLiteral);
+                            if (nli >= 0) {
+                                // found in concatenation
+                                if (nli>0) nli--; // walk back the space
+                                String putBackOnInput = (value + vs).substring(nli);
+                                value = (value + vs).substring(0, nli);
+                                value = Strings.trimEnd(value);
+                                inputTokens.remove(0);
+                                if (removeLiteralFromInput) putBackOnInput = putBackOnInput.substring(nextLiteral.length());
+                                if (!putBackOnInput.isEmpty()) inputTokens.add(0, new ParseValue(putBackOnInput));
+                                foundLiteral = true;
+                                break;
+                            } else {
+                                // literal not found
+                                value += vs;
+                                inputTokens.remove(0);
+                                if (inputTokens.isEmpty()) {
+                                    foundLiteral = false;
+                                    break;
+                                } else {
+                                    nextInput = inputTokens.iterator().next();
+                                    continue;
+                                }
+                            }
+                        }
+                    }
+                    foundContentsPriorToLiteral = Strings.isNonEmpty(value);
+                } else {
+                    // next is not a literal, so take the next, and caller will probably recurse, taking the entire rest as the value
+                    value = getSingleStringContents(nextInput, "taking remainder when no literal").or("");
+                    foundLiteral = false;
+                    foundContentsPriorToLiteral = true;
+                    inputTokens.remove(0);
+                }
+            }
+            return Pair.of(value, foundContentsPriorToLiteral);
+        }
+    }
+
+}
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessorQst.java b/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessorQst.java
new file mode 100644
index 0000000..99da682
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/ShorthandProcessorQst.java
@@ -0,0 +1,357 @@
+/*
+ * 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.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+
+import org.apache.brooklyn.util.collections.CollectionMerger;
+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.QuotedStringTokenizer;
+import org.apache.brooklyn.util.text.Strings;
+
+/**
+ * This is the original processor which relied purely on the QST.
+ *
+ * ===
+ *
+ * Accepts a shorthand template, and converts it to a map of values,
+ * e.g. given template "[ ?${type_set} ${sensor.type} ] ${sensor.name} \"=\" ${value}"
+ * and input "integer foo=3", this will return
+ * { sensor: { type: integer, name: foo }, value: 3, type_set: true }.
+ *
+ * Expects space-separated TOKEN where TOKEN is either:
+ *
+ * ${VAR} - to set VAR, which should be of the regex [A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*, with dot separation used to set nested maps;
+ *   will match a quoted string if supplied, else up to the next literal if the next token is a literal, else the next work.
+ * ${VAR...} - as above, but will collect multiple args if needed (if the next token is a literal matched further on, or if at end of word)
+ * "LITERAL" - to expect a literal expression. this must include the quotation marks and should include spaces if spaces are required.
+ * [ TOKEN ] - to indicate TOKEN is optional, where TOKEN is one of the above sections. parsing is attempted first with it, then without it.
+ * [ ?${VAR} TOKEN ] - as `[ TOKEN ]` but VAR is set true or false depending whether this optional section was matched.
+ *
+ * Would be nice to support A | B (exclusive or) for A or B but not both (where A might contain a literal for disambiguation),
+ * and ( X ) for X required but grouped (for use with | (exclusive or) where one option is required).
+ * Would also be nice to support any order, which could be ( A & B ) to allow A B or B A.
+ *
+ * But for now we've made do without it, with some compromises:
+ * * keywords must follow the order indicated
+ * * exclusive alternatives are disallowed by code subsequently or checked separately (eg Transform)
+ */
+public class ShorthandProcessorQst {
+
+    private final String template;
+    boolean finalMatchRaw = false;
+    boolean failOnMismatch = true;
+
+    public ShorthandProcessorQst(String template) {
+        this.template = template;
+    }
+
+    public Maybe<Map<String,Object>> process(String input) {
+        return new ShorthandProcessorQstAttempt(this, input).call();
+    }
+
+    /** whether the last match should preserve quotes and spaces; default false */
+    public ShorthandProcessorQst withFinalMatchRaw(boolean finalMatchRaw) {
+        this.finalMatchRaw = finalMatchRaw;
+        return this;
+    }
+
+    /** whether to fail on mismatched quotes in the input, default true */
+    public ShorthandProcessorQst withFailOnMismatch(boolean failOnMismatch) {
+        this.failOnMismatch = failOnMismatch;
+        return this;
+    }
+
+    static class ShorthandProcessorQstAttempt {
+        private final List<String> templateTokens;
+        private final String inputOriginal;
+        private final QuotedStringTokenizer qst;
+        private final String template;
+        private final ShorthandProcessorQst options;
+        int optionalDepth = 0;
+        int optionalSkippingInput = 0;
+        private String inputRemaining;
+        Map<String, Object> result;
+        Consumer<String> valueUpdater;
+
+        ShorthandProcessorQstAttempt(ShorthandProcessorQst proc, String input) {
+            this.template = proc.template;
+            this.options = proc;
+            this.qst = qst(template);
+            this.templateTokens = qst.remainderAsList();
+            this.inputOriginal = input;
+        }
+
+        private QuotedStringTokenizer qst(String x) {
+            return QuotedStringTokenizer.builder().includeQuotes(true).includeDelimiters(false).expectQuotesDelimited(true).failOnOpenQuote(options.failOnMismatch).build(x);
+        }
+
+        public synchronized Maybe<Map<String,Object>> call() {
+            if (result == null) {
+                result = MutableMap.of();
+                inputRemaining = inputOriginal;
+            } else {
+                throw new IllegalStateException("Only allowed to use once");
+            }
+            Maybe<Object> error = doCall();
+            if (error.isAbsent()) return Maybe.Absent.castAbsent(error);
+            inputRemaining = Strings.trimStart(inputRemaining);
+            if (Strings.isNonBlank(inputRemaining)) {
+                if (valueUpdater!=null) {
+                    QuotedStringTokenizer qstInput = qst(inputRemaining);
+                    valueUpdater.accept(getRemainderPossiblyRaw(qstInput));
+                } else {
+                    // shouldn't come here
+                    return Maybe.absent("Input has trailing characters after template is matched: '" + inputRemaining + "'");
+                }
+            }
+            return Maybe.of(result);
+        }
+
+        protected Maybe<Object> doCall() {
+            boolean isEndOfOptional = false;
+            outer: while (true) {
+                if (isEndOfOptional) {
+                    if (optionalDepth <= 0) {
+                        throw new IllegalStateException("Unexpected optional block closure");
+                    }
+                    optionalDepth--;
+                    if (optionalSkippingInput>0) {
+                        // we were in a block where we skipped something optional because it couldn't be matched; outer parser is now canonical,
+                        // and should stop skipping
+                        return Maybe.of(true);
+                    }
+                    isEndOfOptional = false;
+                }
+
+                if (templateTokens.isEmpty()) {
+                    if (Strings.isNonBlank(inputRemaining) && valueUpdater==null) {
+                        return Maybe.absent("Input has trailing characters after template is matched: '" + inputRemaining + "'");
+                    }
+                    if (optionalDepth>0)
+                        return Maybe.absent("Mismatched optional marker in template");
+                    return Maybe.of(true);
+                }
+                String t = templateTokens.remove(0);
+
+                if (t.startsWith("[")) {
+                    t = t.substring(1);
+                    if (!t.isEmpty()) {
+                        templateTokens.add(0, t);
+                    }
+                    String optionalPresentVar = null;
+                    if (!templateTokens.isEmpty() && templateTokens.get(0).startsWith("?")) {
+                        String v = templateTokens.remove(0);
+                        if (v.startsWith("?${") && v.endsWith("}")) {
+                            optionalPresentVar = v.substring(3, v.length() - 1);
+                        } else {
+                            throw new IllegalStateException("? after [ should indicate optional presence variable using syntax '?${var}', not '"+v+"'");
+                        }
+                    }
+                    Maybe<Object> cr;
+                    if (optionalSkippingInput<=0) {
+                        // make a deep copy so that valueUpdater writes get replayed
+                        Map<String, Object> backupResult = (Map) CollectionMerger.builder().deep(true).build().merge(MutableMap.of(), result);
+                        Consumer<String> backupValueUpdater = valueUpdater;
+                        String backupInputRemaining = inputRemaining;
+                        List<String> backupTemplateTokens = MutableList.copyOf(templateTokens);
+                        int oldDepth = optionalDepth;
+                        int oldSkippingDepth = optionalSkippingInput;
+
+                        optionalDepth++;
+                        cr = doCall();
+                        if (cr.isPresent()) {
+                            // succeeded
+                            if (optionalPresentVar!=null) result.put(optionalPresentVar, true);
+                            continue;
+
+                        } else {
+                            // restore
+                            result = backupResult;
+                            valueUpdater = backupValueUpdater;
+                            if (optionalPresentVar!=null) result.put(optionalPresentVar, false);
+                            inputRemaining = backupInputRemaining;
+                            templateTokens.clear();
+                            templateTokens.addAll(backupTemplateTokens);
+                            optionalDepth = oldDepth;
+                            optionalSkippingInput = oldSkippingDepth;
+
+                            optionalSkippingInput++;
+                            optionalDepth++;
+                            cr = doCall();
+                            if (cr.isPresent()) {
+                                optionalSkippingInput--;
+                                continue;
+                            }
+                        }
+                    } else {
+                        if (optionalPresentVar!=null) {
+                            result.put(optionalPresentVar, false);
+                            valueUpdater = null;
+                        }
+                        optionalDepth++;
+                        cr = doCall();
+                        if (cr.isPresent()) {
+                            continue;
+                        }
+                    }
+                    return cr;
+                }
+
+                isEndOfOptional = t.endsWith("]");
+
+                if (isEndOfOptional) {
+                    t = t.substring(0, t.length() - 1);
+                    if (t.isEmpty()) continue;
+                    // next loop will process the end of the optionality
+                }
+
+                if (qst.isQuoted(t)) {
+                    if (optionalSkippingInput>0) continue;
+
+                    String literal = qst.unwrapIfQuoted(t);
+                    do {
+                        // ignore leading spaces (since the quoted string tokenizer will have done that anyway); but their _absence_ can be significant for intra-token searching when matching a var
+                        inputRemaining = Strings.trimStart(inputRemaining);
+                        if (inputRemaining.startsWith(Strings.trimStart(literal))) {
+                            // literal found
+                            inputRemaining = inputRemaining.substring(Strings.trimStart(literal).length());
+                            continue outer;
+                        }
+                        if (inputRemaining.isEmpty()) return Maybe.absent("Literal '"+literal+"' expected, when end of input reached");
+                        if (valueUpdater!=null) {
+                            QuotedStringTokenizer qstInput = qst(inputRemaining);
+                            if (!qstInput.hasMoreTokens()) return Maybe.absent("Literal '"+literal+"' expected, when end of input tokens reached");
+                            String value = getNextInputTokenUpToPossibleExpectedLiteral(qstInput, literal);
+                            valueUpdater.accept(value);
+                            continue;
+                        }
+                        return Maybe.absent("Literal '"+literal+"' expected, when encountered '"+inputRemaining+"'");
+                    } while (true);
+                }
+
+                if (t.startsWith("${") && t.endsWith("}")) {
+                    if (optionalSkippingInput>0) continue;
+
+                    t = t.substring(2, t.length()-1);
+                    String value;
+
+                    inputRemaining = inputRemaining.trim();
+                    QuotedStringTokenizer qstInput = qst(inputRemaining);
+                    if (!qstInput.hasMoreTokens()) return Maybe.absent("End of input when looking for variable "+t);
+
+                    if (!templateTokens.stream().filter(x -> !x.equals("]")).findFirst().isPresent()) {
+                        // last word (whether optional or not) takes everything
+                        value = getRemainderPossiblyRaw(qstInput);
+                        inputRemaining = "";
+
+                    } else {
+                        value = getNextInputTokenUpToPossibleExpectedLiteral(qstInput, null);
+                    }
+                    boolean multiMatch = t.endsWith("...");
+                    if (multiMatch) t = Strings.removeFromEnd(t, "...");
+                    String keys[] = t.split("\\.");
+                    final String tt = t;
+                    valueUpdater = v2 -> {
+                        Map target = result;
+                        for (int i=0; i<keys.length; i++) {
+                            if (!Pattern.compile("[A-Za-z0-9_-]+").matcher(keys[i]).matches()) {
+                                throw new IllegalArgumentException("Invalid variable '"+tt+"'");
+                            }
+                            if (i == keys.length - 1) {
+                                target.compute(keys[i], (k, v) -> v == null ? v2 : v + " " + v2);
+                            } else {
+                                // need to make sure we have a map or null
+                                target = (Map) target.compute(keys[i], (k, v) -> {
+                                    if (v == null) return MutableMap.of();
+                                    if (v instanceof Map) return v;
+                                    return Maybe.absent("Cannot process shorthand for " + Arrays.asList(keys) + " because entry '" + k + "' is not a map (" + v + ")");
+                                });
+                            }
+                        }
+                    };
+                    valueUpdater.accept(value);
+                    if (!multiMatch) valueUpdater = null;
+                    continue;
+                }
+
+                // unexpected token
+                return Maybe.absent("Unexpected token in shorthand pattern '"+template+"' at position "+(template.lastIndexOf(t)+1));
+            }
+        }
+
+        private String getRemainderPossiblyRaw(QuotedStringTokenizer qstInput) {
+            String value;
+            value = Strings.join(qstInput.remainderRaw(), "");
+            if (!options.finalMatchRaw) {
+                return qstInput.unwrapIfQuoted(value);
+            }
+            return value;
+        }
+
+        private String getNextInputTokenUpToPossibleExpectedLiteral(QuotedStringTokenizer qstInput, String nextLiteral) {
+            String value;
+            String v = qstInput.nextToken();
+            if (qstInput.isQuoted(v)) {
+                // input was quoted, eg "\"foo=b\" ..." -- ignore the = in "foo=b"
+                value = qstInput.unwrapIfQuoted(v);
+                inputRemaining = inputRemaining.substring(v.length());
+            } else {
+                // input not quoted, if next template token is literal, look for it
+                boolean isLiteralExpected;
+                if (nextLiteral==null) {
+                    nextLiteral = templateTokens.get(0);
+                    if (qstInput.isQuoted(nextLiteral)) {
+                        nextLiteral = qstInput.unwrapIfQuoted(nextLiteral);
+                        isLiteralExpected = true;
+                    } else {
+                        isLiteralExpected = false;
+                    }
+                } else {
+                    isLiteralExpected = true;
+                }
+                if (isLiteralExpected) {
+                    int nli = v.indexOf(nextLiteral);
+                    if (nli>0) {
+                        // literal found in unquoted string, eg "foo=bar" when literal is =
+                        value = v.substring(0, nli);
+                        inputRemaining = inputRemaining.substring(value.length());
+                    } else {
+                        // literal not found
+                        value = v;
+                        inputRemaining = inputRemaining.substring(value.length());
+                    }
+                } else {
+                    // next is not a literal, so the whole token is the value
+                    value = v;
+                    inputRemaining = inputRemaining.substring(value.length());
+                }
+            }
+            return value;
+        }
+    }
+
+
+}
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/WorkflowStepDefinition.java b/core/src/main/java/org/apache/brooklyn/core/workflow/WorkflowStepDefinition.java
index f51d962..72f04fc 100644
--- a/core/src/main/java/org/apache/brooklyn/core/workflow/WorkflowStepDefinition.java
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/WorkflowStepDefinition.java
@@ -23,7 +23,6 @@
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.common.base.Preconditions;
-import com.google.common.reflect.TypeToken;
 import org.apache.brooklyn.api.mgmt.ManagementContext;
 import org.apache.brooklyn.api.mgmt.Task;
 import org.apache.brooklyn.config.ConfigKey;
@@ -159,11 +158,11 @@
     abstract public void populateFromShorthand(String value);
 
     protected void populateFromShorthandTemplate(String template, String value) {
-        populateFromShorthandTemplate(template, value, false, true, true);
+        populateFromShorthandTemplate(template, value, false, true);
     }
 
-    protected Map<String, Object> populateFromShorthandTemplate(String template, String value, boolean finalMatchRaw, boolean failOnMismatch, boolean failOnError) {
-        Maybe<Map<String, Object>> result = new ShorthandProcessor(template).withFinalMatchRaw(finalMatchRaw).withFailOnMismatch(failOnMismatch).process(value);
+    protected Map<String, Object> populateFromShorthandTemplate(String template, String value, boolean finalMatchRaw, boolean failOnError) {
+        Maybe<Map<String, Object>> result = new ShorthandProcessor(template).withFinalMatchRaw(finalMatchRaw).process(value);
         if (result.isAbsent()) {
             if (failOnError) throw new IllegalArgumentException("Invalid shorthand expression: '" + value + "'", Maybe.Absent.getException(result));
             return null;
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/appmodel/UpdateChildrenWorkflowStep.java b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/appmodel/UpdateChildrenWorkflowStep.java
index 27ed3d0..0db1cf6 100644
--- a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/appmodel/UpdateChildrenWorkflowStep.java
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/appmodel/UpdateChildrenWorkflowStep.java
@@ -241,7 +241,11 @@
             stepState.parent = parentId!=null ? WorkflowStepResolution.findEntity(context, parentId).get() : context.getEntity();
 
             stepState.identifier_expression = TypeCoercions.coerce(context.getInputRaw(IDENTIFIER_EXRPESSION.getName()), String.class);
-            stepState.items = context.getInput(ITEMS);
+            try {
+                stepState.items = context.getInput(ITEMS);
+            } catch (Exception e) {
+                throw new IllegalStateException("Cannot resolve items as a list", e);
+            }
             if (stepState.items==null) throw new IllegalStateException("Items cannot be null");
             setStepState(context, stepState);
         } else {
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/SetVariableWorkflowStep.java b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/SetVariableWorkflowStep.java
index e809e76..f363261 100644
--- a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/SetVariableWorkflowStep.java
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/SetVariableWorkflowStep.java
@@ -74,7 +74,7 @@
 
     @Override
     public void populateFromShorthand(String expression) {
-        Map<String, Object> newInput = populateFromShorthandTemplate(SHORTHAND, expression, true, true, true);
+        Map<String, Object> newInput = populateFromShorthandTemplate(SHORTHAND, expression, true, true);
         if (newInput.get(VALUE.getName())!=null && input.get(INTERPOLATION_MODE.getName())==null) {
             setInput(INTERPOLATION_MODE, InterpolationMode.WORDS);
         }
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformReplace.java b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformReplace.java
index 16f8b49..f073cbd 100644
--- a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformReplace.java
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformReplace.java
@@ -19,10 +19,8 @@
 package org.apache.brooklyn.core.workflow.steps.variables;
 
 import org.apache.brooklyn.core.workflow.ShorthandProcessor;
-import org.apache.brooklyn.core.workflow.WorkflowExecutionContext;
 import org.apache.brooklyn.util.guava.Maybe;
 
-import java.util.List;
 import java.util.Map;
 import java.util.regex.Pattern;
 
@@ -40,7 +38,6 @@
     protected void initCheckingDefinition() {
         Maybe<Map<String, Object>> maybeResult = new ShorthandProcessor(SHORTHAND)
                 .withFinalMatchRaw(false)
-                .withFailOnMismatch(true)
                 .process(transformDef);
 
         if (maybeResult.isPresent()) {
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformSplit.java b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformSplit.java
index d5b1779..1f9b3bb 100644
--- a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformSplit.java
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformSplit.java
@@ -18,22 +18,14 @@
  */
 package org.apache.brooklyn.core.workflow.steps.variables;
 
-import java.util.Arrays;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-import java.util.stream.Collectors;
 
-import com.google.common.base.Splitter;
 import org.apache.brooklyn.core.workflow.ShorthandProcessor;
-import org.apache.brooklyn.core.workflow.WorkflowExpressionResolution;
 import org.apache.brooklyn.util.collections.MutableList;
 import org.apache.brooklyn.util.guava.Maybe;
-import org.apache.brooklyn.util.javalang.Boxing;
-import org.apache.brooklyn.util.text.Strings;
-import org.apache.commons.lang3.StringUtils;
 
 public class TransformSplit extends WorkflowTransformDefault {
 
@@ -47,7 +39,6 @@
     protected void initCheckingDefinition() {
         Maybe<Map<String, Object>> maybeResult = new ShorthandProcessor(SHORTHAND)
                 .withFinalMatchRaw(false)
-                .withFailOnMismatch(true)
                 .process(transformDef);
 
         if (maybeResult.isPresent()) {
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformVariableWorkflowStep.java b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformVariableWorkflowStep.java
index fc59c55..7f7c2f8 100644
--- a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformVariableWorkflowStep.java
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformVariableWorkflowStep.java
@@ -86,7 +86,7 @@
     public void populateFromShorthand(String expression) {
         Map<String, Object> match = null;
         for (int i=0; i<SHORTHAND_OPTIONS.length && match==null; i++)
-            match = populateFromShorthandTemplate(SHORTHAND_OPTIONS[i], expression, false, true, false);
+            match = populateFromShorthandTemplate(SHORTHAND_OPTIONS[i], expression, false, false);
 
         if (match==null && Strings.isNonBlank(expression)) {
             throw new IllegalArgumentException("Invalid shorthand expression for transform: '" + expression + "'");
diff --git a/core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorTest.java b/core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorEpToQstTest.java
similarity index 79%
copy from core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorTest.java
copy to core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorEpToQstTest.java
index bb52a32..bfe9308 100644
--- a/core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorTest.java
+++ b/core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorEpToQstTest.java
@@ -18,17 +18,15 @@
  */
 package org.apache.brooklyn.core.workflow;
 
+import java.util.Map;
+import java.util.function.Consumer;
+
 import org.apache.brooklyn.core.test.BrooklynMgmtUnitTestSupport;
-import org.apache.brooklyn.core.workflow.steps.variables.TransformVariableWorkflowStep;
 import org.apache.brooklyn.test.Asserts;
 import org.apache.brooklyn.util.collections.MutableMap;
 import org.testng.annotations.Test;
 
-import java.util.Map;
-import java.util.function.Consumer;
-import java.util.function.Predicate;
-
-public class ShorthandProcessorTest extends BrooklynMgmtUnitTestSupport {
+public class ShorthandProcessorEpToQstTest extends BrooklynMgmtUnitTestSupport {
 
     void assertShorthandOfGives(String template, String input, Map<String,Object> expected) {
         Asserts.assertEquals(new ShorthandProcessor(template).process(input).get(), expected);
@@ -72,8 +70,20 @@
         // if you want quotes, you have to wrap them in quotes
         assertShorthandOfGives("${x} \" is \" ${y}", "\"this is b\" is \"\\\"c is is quoted\\\"\"", MutableMap.of("x", "this is b", "y", "\"c is is quoted\""));
         // and only quotes at word end are considered, per below
-        assertShorthandOfGivesError("${x} \" is \" ${y}", "\"this is b\" is \"\\\"c is \"is  quoted\"\\\"\"", MutableMap.of("x", "this is b", "y", "\"c is \"is  quoted\"\""));
-        assertShorthandOfGivesError("${x} \" is \" ${y}", "\"this is b\" is \"\\\"c is \"is  quoted\"\\\"\"  too", MutableMap.of("x", "this is b", "y", "\"\\\"c is \"is  quoted\"\\\"\"  too"));
+
+        // but unlike pure QST we recognize double quotes like everyone else now, so the following behaviour is changed
+        if (ShorthandProcessorEpToQst.TRY_HARDER_FOR_QST_COMPATIBILITY) {
+            assertShorthandOfGivesError("${x} \" is \" ${y}", "\"this is b\" is \"\\\"c is \"is  quoted\"\\\"\"",
+                    MutableMap.of("x", "this is b", "y",
+//                            "\"c is \"is  quoted\"\""
+                            "\"\\\"c is \"is  quoted\"\\\"\""
+                    ));
+            assertShorthandOfGivesError("${x} \" is \" ${y}", "\"this is b\" is \"\\\"c is \"is  quoted\"\\\"\"  too",
+                    MutableMap.of("x", "this is b", "y", "\"\\\"c is \"is  quoted\"\\\"\"  too"));
+        }
+        // and this is new
+        assertShorthandOfGives("${x} \" is \" ${y}", "\"this is b\" is \"\\\"c is \\\"is  quoted\"", MutableMap.of("x", "this is b", "y", "\"c is \"is  quoted"));
+        assertShorthandOfGives("${x} \" is \" ${y}", "\"this is b\" is \"\\\"c is \\\"is  quoted\"  too", MutableMap.of("x", "this is b", "y", "\"\\\"c is \\\"is  quoted\"  too"));
 
         // preserve spaces in a word
         assertShorthandOfGives("${x}", "\"  sp a  ces \"", MutableMap.of("x", "  sp a  ces "));
@@ -86,8 +96,12 @@
         // a close quote must come at a word end to be considered
         // so this gives an error
         assertShorthandFailsWith("${x}", "\"c is  \"is", e -> Asserts.expectedFailureContainsIgnoreCase(e, "mismatched", "quot"));
-        // and this is treated as one quoted string
-        assertShorthandOfGivesError("${x}", "\"\\\"c  is \"is  quoted\"\\\"\"", MutableMap.of("x", "\"c  is \"is  quoted\"\""));
+
+        // and this WAS treated as one quoted string
+        assertShorthandOfGivesError("${x}", "\"\\\"c  is \"is  quoted\"\\\"\"",
+//                MutableMap.of("x", "\"c  is \"is  quoted\"\""));
+                // but now two better, as two quoted strings
+                MutableMap.of("x", "\"\\\"c  is \"is  quoted\"\\\"\""));
     }
 
     @Test
@@ -162,4 +176,20 @@
         assertShorthandOfGives("[ [ ${a} ] ${b} [ \"=\" ${c...} ] ]", "a b = c", MutableMap.of("a", "a", "b", "b", "c", "c"));
     }
 
+    @Test
+    public void testTokensVsWords() {
+        assertShorthandOfGives("[ [ ${type} ] ${var} [ \"=\" ${val...} ] ]",
+                "int foo['bar'] = baz", MutableMap.of("type", "int", "var", "foo['bar']", "val", "baz"));
+
+        // THIS IS STILL NOT DONE BECAUSE ${var} matches a "word";
+        // to support that, we need to delay setting a variable when followed by an optional literal until we are processing that literal;
+        // like "multi-match" mode but "single match" which calls back to the getUpToLiteral once the literal is known.
+        // with valueUpdater that shouldn't be too hard.
+//        assertShorthandOfGives("[ [ ${type} ] ${var} [ \"=\" ${val...} ] ]",
+//                "int foo['bar']=baz", MutableMap.of("type", "int",
+//                        "var", "foo['bar']", "val", "baz"
+//                        "var", "foo['bar']=baz"
+//                ));
+    }
+
 }
diff --git a/core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorTest.java b/core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorQstTest.java
similarity index 91%
rename from core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorTest.java
rename to core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorQstTest.java
index bb52a32..dbb63d1 100644
--- a/core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorTest.java
+++ b/core/src/test/java/org/apache/brooklyn/core/workflow/ShorthandProcessorQstTest.java
@@ -18,34 +18,32 @@
  */
 package org.apache.brooklyn.core.workflow;
 
+import java.util.Map;
+import java.util.function.Consumer;
+
 import org.apache.brooklyn.core.test.BrooklynMgmtUnitTestSupport;
-import org.apache.brooklyn.core.workflow.steps.variables.TransformVariableWorkflowStep;
 import org.apache.brooklyn.test.Asserts;
 import org.apache.brooklyn.util.collections.MutableMap;
 import org.testng.annotations.Test;
 
-import java.util.Map;
-import java.util.function.Consumer;
-import java.util.function.Predicate;
-
-public class ShorthandProcessorTest extends BrooklynMgmtUnitTestSupport {
+public class ShorthandProcessorQstTest extends BrooklynMgmtUnitTestSupport {
 
     void assertShorthandOfGives(String template, String input, Map<String,Object> expected) {
-        Asserts.assertEquals(new ShorthandProcessor(template).process(input).get(), expected);
+        Asserts.assertEquals(new ShorthandProcessorQst(template).process(input).get(), expected);
     }
 
     void assertShorthandOfGivesError(String template, String input, Map<String,Object> expected) {
-        Asserts.assertEquals(new ShorthandProcessor(template).withFailOnMismatch(false).process(input).get(), expected);
-        Asserts.assertFails(() -> new ShorthandProcessor(template).withFailOnMismatch(true).process(input).get());
+        Asserts.assertEquals(new ShorthandProcessorQst(template).withFailOnMismatch(false).process(input).get(), expected);
+        Asserts.assertFails(() -> new ShorthandProcessorQst(template).withFailOnMismatch(true).process(input).get());
     }
 
     void assertShorthandFinalMatchRawOfGives(String template, String input, Map<String,Object> expected) {
-        Asserts.assertEquals(new ShorthandProcessor(template).withFinalMatchRaw(true).process(input).get(), expected);
+        Asserts.assertEquals(new ShorthandProcessorQst(template).withFinalMatchRaw(true).process(input).get(), expected);
     }
 
     void assertShorthandFailsWith(String template, String input, Consumer<Exception> check) {
         try {
-            new ShorthandProcessor(template).process(input).get();
+            new ShorthandProcessorQst(template).process(input).get();
             Asserts.shouldHaveFailedPreviously();
         } catch (Exception e) {
             check.accept(e);
@@ -162,4 +160,10 @@
         assertShorthandOfGives("[ [ ${a} ] ${b} [ \"=\" ${c...} ] ]", "a b = c", MutableMap.of("a", "a", "b", "b", "c", "c"));
     }
 
+    @Test
+    public void testTokensVsWords() {
+        assertShorthandOfGives("[ [ ${type} ] ${var} [ \"=\" ${val...} ] ]",
+                "int foo['bar'] = baz", MutableMap.of("type", "int", "var", "foo['bar']", "val", "baz"));
+    }
+
 }