support dot property access in DSL, and transform get, join, split
diff --git a/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/BrooklynDslInterpreter.java b/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/BrooklynDslInterpreter.java
index 9481512..c42b799 100644
--- a/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/BrooklynDslInterpreter.java
+++ b/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/BrooklynDslInterpreter.java
@@ -66,6 +66,9 @@
         try {
             currentNode.set(node);
             Object parsedNode = new DslParser(expression).parse();
+            if (parsedNode instanceof PropertyAccess) {
+                parsedNode = new FunctionWithArgs(""+((PropertyAccess)parsedNode).getSelector(), null);
+            }
             if ((parsedNode instanceof FunctionWithArgs) && ((FunctionWithArgs)parsedNode).getArgs()==null) {
                 if (node.getRoleInParent() == Role.MAP_KEY) {
                     node.setNewValue(parsedNode);
@@ -90,11 +93,16 @@
     @Override
     public boolean applyMapEntry(PlanInterpretationNode node, Map<Object, Object> mapIn, Map<Object, Object> mapOut,
             PlanInterpretationNode key, PlanInterpretationNode value) {
-        if (key.getNewValue() instanceof FunctionWithArgs) {
+        Object knv = key.getNewValue();
+        if (knv instanceof PropertyAccess) {
+            // when property access is used as a key, it is a function without args
+            knv = new FunctionWithArgs(""+((PropertyAccess)knv).getSelector(), null);
+        }
+        if (knv instanceof FunctionWithArgs) {
             try {
                 currentNode.set(node);
 
-                FunctionWithArgs f = (FunctionWithArgs) key.getNewValue();
+                FunctionWithArgs f = (FunctionWithArgs) knv;
                 if (f.getArgs()!=null)
                     throw new IllegalStateException("Invalid map key function "+f.getFunction()+"; should not have arguments if taking arguments from map");
 
diff --git a/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/methods/BrooklynDslCommon.java b/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/methods/BrooklynDslCommon.java
index 0468930..6dde3ff 100644
--- a/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/methods/BrooklynDslCommon.java
+++ b/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/methods/BrooklynDslCommon.java
@@ -48,6 +48,7 @@
 import org.apache.brooklyn.camp.brooklyn.spi.dsl.BrooklynDslDeferredSupplier;
 import org.apache.brooklyn.camp.brooklyn.spi.dsl.DslAccessible;
 import org.apache.brooklyn.camp.brooklyn.spi.dsl.methods.DslComponent.Scope;
+import org.apache.brooklyn.camp.brooklyn.spi.dsl.parse.WorkflowTransformGet;
 import org.apache.brooklyn.config.ConfigKey;
 import org.apache.brooklyn.core.config.ConfigKeys;
 import org.apache.brooklyn.core.config.external.ExternalConfigSupplier;
@@ -65,6 +66,7 @@
 import org.apache.brooklyn.core.sensor.DependentConfiguration;
 import org.apache.brooklyn.core.typereg.RegisteredTypeLoadingContexts;
 import org.apache.brooklyn.core.typereg.RegisteredTypes;
+import org.apache.brooklyn.core.workflow.steps.variables.TransformVariableWorkflowStep;
 import org.apache.brooklyn.util.collections.Jsonya;
 import org.apache.brooklyn.util.collections.MutableList;
 import org.apache.brooklyn.util.collections.MutableMap;
@@ -116,6 +118,7 @@
         BrooklynJacksonSerializationUtils.JsonDeserializerForCommonBrooklynThings.BROOKLYN_PARSE_DSL_FUNCTION = DslUtils::parseBrooklynDsl;
         BrooklynObjectsJsonMapper.DslToStringSerialization.BROOKLYN_DSL_INTERFACE = BrooklynDslDeferredSupplier.class;
         registerSpecCoercionAdapter();
+        registerWorkflowTransforms();
         INITIALIZED = true;
     }
     private static boolean INITIALIZED = false;
@@ -163,6 +166,9 @@
             }
         });
     }
+    public static void registerWorkflowTransforms() {
+        TransformVariableWorkflowStep.registerTransformation("get", () -> new WorkflowTransformGet());
+    }
     
     // Access specific entities
 
@@ -463,7 +469,7 @@
         return new DslLiteral(expression);
     }
 
-    protected final static class DslLiteral extends BrooklynDslDeferredSupplier<Object> {
+    public final static class DslLiteral extends BrooklynDslDeferredSupplier<Object> {
         final String literalString;
         final String literalObjectJson;
 
diff --git a/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/parse/DslParser.java b/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/parse/DslParser.java
index 71b4677..846a1ef 100644
--- a/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/parse/DslParser.java
+++ b/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/parse/DslParser.java
@@ -21,6 +21,8 @@
 import java.util.Collection;
 import java.util.List;
 
+import com.fasterxml.jackson.databind.annotation.JsonAppend;
+import com.fasterxml.jackson.databind.annotation.JsonAppend.Prop;
 import org.apache.brooklyn.util.collections.MutableList;
 
 public class DslParser {
@@ -83,8 +85,10 @@
                 char c = expression.charAt(index);
                 if (Character.isJavaIdentifierPart(c)) ;
                     // these chars also permitted
-                else if (".:".indexOf(c)>=0) ;
-                    // other things e.g. whitespace, parentheses, etc, skip
+                else if (c==':') ;
+                else if (c=='.' && expression.substring(0, index).endsWith("function")) ;  // function.xxx used for some static DslAccessible functions
+
+                    // other things e.g. whitespace, parentheses, etc, beak on
                 else break;
                 index++;
             } while (true);
@@ -98,7 +102,12 @@
             // collect arguments
             int parenStart = index;
             List<Object> args = new MutableList<>();
-            if (expression.charAt(index)=='[' && expression.charAt(index +1)=='"') { index ++;} // for ["x"] syntax needs to be increased to extract the name of the property correctly
+            if (expression.charAt(index)=='[') {
+                if (!fn.isEmpty()) {
+                    return new PropertyAccess(fn);
+                }
+                if (expression.charAt(index +1)=='"') { index ++;} // for ["x"] syntax needs to be increased to extract the name of the property correctly
+            }
             index ++;
             do {
                 skipWhitespace();
@@ -120,7 +129,9 @@
 
             if (fn.isEmpty()) {
                 Object arg = args.get(0);
-                if (arg instanceof FunctionWithArgs) {
+                if (arg instanceof PropertyAccess) {
+                    result.add(arg);
+                } else if (arg instanceof FunctionWithArgs) {
                     FunctionWithArgs holder = (FunctionWithArgs) arg;
                     if(holder.getArgs() == null || holder.getArgs().isEmpty()) {
                         result.add(new PropertyAccess(holder.getFunction()));
@@ -135,37 +146,43 @@
             }
 
             index++;
-            skipWhitespace();
-            if (index >= expression.length())
-                return result;
-            char c = expression.charAt(index);
-            if (c=='.') {
-                // chained expression
-                int chainStart = index;
-                index++;
-                Object next = next();
-                if (next instanceof List) {
-                    result.addAll((Collection<? extends FunctionWithArgs>) next);
+            do {
+                skipWhitespace();
+                if (index >= expression.length())
                     return result;
+                char c = expression.charAt(index);
+                if (c == '.') {
+                    // chained expression
+                    int chainStart = index;
+                    index++;
+                    Object next = next();
+                    if (next instanceof List) {
+                        result.addAll((Collection<?>) next);
+                    } else if (next instanceof PropertyAccess) {
+                        result.add(next);
+                    } else {
+                        throw new IllegalStateException("Expected functions following position " + chainStart);
+                    }
+                } else if (c == '[') {
+                    int selectorsStart = index;
+                    Object next = next();
+                    if (next instanceof List) {
+                        result.addAll((Collection<? extends PropertyAccess>) next);
+                        skipWhitespace();
+                        if (index >= expression.length())
+                            return result;
+                    } else {
+                        throw new IllegalStateException("Expected property selectors following position " + selectorsStart);
+                    }
                 } else {
-                    throw new IllegalStateException("Expected functions following position "+chainStart);
-                }
-            } else if (c=='[') {
-                int selectorsStart = index;
-                Object next = next();
-                if (next instanceof List) {
-                    result.addAll((Collection<? extends PropertyAccess>) next);
+                    // following word not something handled at this level; assume parent will handle (or throw) - e.g. a , or extra )
                     return result;
-                } else {
-                    throw new IllegalStateException("Expected property selectors following position "+selectorsStart);
                 }
-            } else {
-                // following word not something handled at this level; assume parent will handle (or throw) - e.g. a , or extra )
-                return result;
-            }
+            } while (true);
         } else {
-            // it is just a word; return it with args as null;
-            return new FunctionWithArgs(fn, null);
+            // previously we returned a null-arg function; now we treat as explicit property access,
+            // and places that need it as a function with arguments from a map key convert it on to new FunctionWithArgs(selector, null)
+            return new PropertyAccess(fn);
         }
     }
 
diff --git a/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/parse/WorkflowTransformGet.java b/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/parse/WorkflowTransformGet.java
new file mode 100644
index 0000000..66ae498
--- /dev/null
+++ b/camp/camp-brooklyn/src/main/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/parse/WorkflowTransformGet.java
@@ -0,0 +1,78 @@
+/*
+ * 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.camp.brooklyn.spi.dsl.parse;
+
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Supplier;
+
+import org.apache.brooklyn.camp.brooklyn.spi.dsl.BrooklynDslDeferredSupplier;
+import org.apache.brooklyn.camp.brooklyn.spi.dsl.BrooklynDslInterpreter;
+import org.apache.brooklyn.camp.brooklyn.spi.dsl.methods.BrooklynDslCommon.DslLiteral;
+import org.apache.brooklyn.core.workflow.WorkflowExpressionResolution;
+import org.apache.brooklyn.core.workflow.steps.variables.WorkflowTransformDefault;
+import org.apache.brooklyn.util.collections.MutableList;
+import org.apache.brooklyn.util.core.text.TemplateProcessor;
+
+public class WorkflowTransformGet extends WorkflowTransformDefault {
+
+    String modifier;
+
+    @Override
+    protected void initCheckingDefinition() {
+        List<String> d = MutableList.copyOf(definition.subList(1, definition.size()));
+        if (d.size()>1) throw new IllegalArgumentException("Transform requires at most a single argument being the index or modifier to get");
+        if (!d.isEmpty()) modifier = d.get(0);
+    }
+
+    @Override
+    public Object apply(Object v) {
+        String modifierResolved = context.resolve(WorkflowExpressionResolution.WorkflowExpressionStage.STEP_RUNNING, modifier, String.class);
+        if (modifierResolved==null) {
+            if (v instanceof Supplier) return ((Supplier)v).get();
+            return v;
+        }
+        modifierResolved = modifierResolved.trim();
+        if (modifierResolved.startsWith("[") || modifierResolved.startsWith(".")) {
+            // already in modifier form
+        } else {
+            if (modifierResolved.contains(".") || modifierResolved.contains("[") || modifierResolved.contains(" ")) {
+                throw new IllegalArgumentException("Argument to 'get' must be a simple key (no spaces, dots, or brackets) or a bracketed string expression or start with an initial dot");
+            } else {
+                modifierResolved = "[\"" + modifierResolved+"\"]";
+            }
+        }
+
+        String m = "$brooklyn:literal(\"ignored\")" + modifierResolved;
+        List parse = (List) new DslParser(m).parse();
+        parse = parse.subList(1, parse.size());
+        // should be a bunch of property access
+        BrooklynDslInterpreter ip = new BrooklynDslInterpreter();
+        Object result = new DslLiteral(v);
+        for (Object p: parse) {
+            if (p instanceof PropertyAccess) {
+                result = ip.evaluateOn(result, (PropertyAccess) p);
+            } else {
+                throw new IllegalArgumentException("Invalid entry in 'get' transform argument; should be property access/modifiers");
+            }
+        }
+        return ((BrooklynDslDeferredSupplier) result).get();
+    }
+
+}
diff --git a/camp/camp-brooklyn/src/test/java/org/apache/brooklyn/camp/brooklyn/WorkflowExpressionsYamlTest.java b/camp/camp-brooklyn/src/test/java/org/apache/brooklyn/camp/brooklyn/WorkflowExpressionsYamlTest.java
index 0b11eab..10c76f5 100644
--- a/camp/camp-brooklyn/src/test/java/org/apache/brooklyn/camp/brooklyn/WorkflowExpressionsYamlTest.java
+++ b/camp/camp-brooklyn/src/test/java/org/apache/brooklyn/camp/brooklyn/WorkflowExpressionsYamlTest.java
@@ -18,11 +18,13 @@
  */
 package org.apache.brooklyn.camp.brooklyn;
 
+import com.google.common.base.Suppliers;
 import com.google.common.collect.Iterables;
 import com.google.common.reflect.TypeToken;
 import org.apache.brooklyn.api.effector.Effector;
 import org.apache.brooklyn.api.entity.Entity;
 import org.apache.brooklyn.api.mgmt.Task;
+import org.apache.brooklyn.camp.brooklyn.spi.dsl.methods.BrooklynDslCommon;
 import org.apache.brooklyn.core.config.ConfigKeys;
 import org.apache.brooklyn.core.entity.Attributes;
 import org.apache.brooklyn.core.entity.Entities;
@@ -36,6 +38,8 @@
 import org.apache.brooklyn.entity.stock.BasicEntityImpl;
 import org.apache.brooklyn.test.Asserts;
 import org.apache.brooklyn.test.ClassLogWatcher;
+import org.apache.brooklyn.util.collections.MutableList;
+import org.apache.brooklyn.util.collections.MutableMap;
 import org.apache.brooklyn.util.core.flags.TypeCoercions;
 import org.apache.brooklyn.util.core.task.Tasks;
 import org.apache.brooklyn.util.exceptions.Exceptions;
@@ -49,6 +53,7 @@
 
 import java.util.concurrent.Callable;
 import java.util.concurrent.TimeUnit;
+import java.util.function.BiFunction;
 import java.util.function.Function;
 
 public class WorkflowExpressionsYamlTest extends AbstractYamlTest {
@@ -340,4 +345,30 @@
         coercedFromMissingId = Entities.submit(lastEntity, Tasks.of("test", () -> TypeCoercions.tryCoerce("does_not_exist", Entity.class))).get();
         Asserts.assertThat(coercedFromMissingId, Maybe::isAbsent);
     }
+
+
+    @Test
+    public void testTransformGet() throws Exception {
+        BrooklynDslCommon.registerSerializationHooks();
+        Entity app = createAndStartApplication(
+                "services:",
+                "- type: " + BasicEntity.class.getName());
+
+        BiFunction<Object,String,Object> get = (input, command) -> {
+            app.config().set(ConfigKeys.newConfigKey(Object.class, "x"), input);
+            return WorkflowBasicTest.runWorkflow(app, " - transform $brooklyn:config(\"x\") | get " + command, "test").getTask(false).get().getUnchecked();
+        };
+
+        Asserts.assertEquals( get.apply(Suppliers.ofInstance(42), ""), 42);
+        Asserts.assertEquals( get.apply(42, ""), 42);
+
+        Asserts.assertEquals( get.apply(MutableList.of(42), "0"), 42);
+        Asserts.assertEquals( get.apply(MutableList.of(42), "[0]"), 42);
+        app.config().set(ConfigKeys.newConfigKey(Object.class, "index"), 0);
+        Asserts.assertEquals( get.apply(MutableList.of(42), "$brooklyn:config(\"index\")"), 42);
+
+        Asserts.assertEquals( get.apply(MutableMap.of("k", MutableList.of(42)), ".k"), MutableList.of(42));
+        Asserts.assertEquals( get.apply(MutableMap.of("k", MutableList.of(42)), "[\"k\"][0]"), 42);
+        Asserts.assertEquals( get.apply(MutableMap.of("k", MutableList.of(42)), ".k[0]"), 42);
+    }
 }
diff --git a/camp/camp-brooklyn/src/test/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/DslParseTest.java b/camp/camp-brooklyn/src/test/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/DslParseTest.java
index ac7f555..79d2b67 100644
--- a/camp/camp-brooklyn/src/test/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/DslParseTest.java
+++ b/camp/camp-brooklyn/src/test/java/org/apache/brooklyn/camp/brooklyn/spi/dsl/DslParseTest.java
@@ -136,22 +136,35 @@
 
     @Test
     public void testParseObjectAttribute() {
-        Object fx = new DslParser("$brooklyn:object(\"[brooklyn.obj.TestObject,host]\").attributeWhenReady(\"ips_container\")[\"ips\"][0]").parse();
-        assertEquals(((List<?>) fx).size(), 4, "" + fx);
+        List fx = (List) new DslParser("$brooklyn:object(\"[brooklyn.obj.TestObject,host]\").attributeWhenReady(\"ips_container\")[\"ips\"][0]").parse();
+        assertEquals(fx.size(), 4, "" + fx);
 
-        Object fx1 = ((List<?>)fx).get(0);
-        assertEquals( ((FunctionWithArgs)fx1).getFunction(), "$brooklyn:object" );
-        assertEquals( ((FunctionWithArgs)fx1).getArgs(), ImmutableList.of(new QuotedString("\"[brooklyn.obj.TestObject,host]\"")) );
+        assertEquals( ((FunctionWithArgs)fx.get(0)).getFunction(), "$brooklyn:object" );
+        assertEquals( ((FunctionWithArgs)fx.get(0)).getArgs(), ImmutableList.of(new QuotedString("\"[brooklyn.obj.TestObject,host]\"")) );
 
-        Object fx2 = ((List<?>)fx).get(1);
-        assertEquals( ((FunctionWithArgs)fx2).getFunction(), "attributeWhenReady" );
-        assertEquals( ((FunctionWithArgs)fx2).getArgs(), ImmutableList.of(new QuotedString("\"ips_container\"")) );
+        assertEquals( ((FunctionWithArgs)fx.get(1)).getFunction(), "attributeWhenReady" );
+        assertEquals( ((FunctionWithArgs)fx.get(1)).getArgs(), ImmutableList.of(new QuotedString("\"ips_container\"")) );
 
-        Object fx3 = ((List<?>)fx).get(2);
-        assertEquals( ((PropertyAccess)fx3).getSelector(), "ips" );
+        assertEquals( ((PropertyAccess)fx.get(2)).getSelector(), "ips" );
+        assertEquals( ((PropertyAccess)fx.get(3)).getSelector(), "0" );
 
-        Object fx4 = ((List<?>)fx).get(3);
-        assertEquals( ((PropertyAccess)fx4).getSelector(), "0" );
+        fx = (List) new DslParser("$brooklyn:object(\"[brooklyn.obj.TestObject,host]\").attributeWhenReady(\"ips_container\").ips[0]").parse();
+        assertEquals(fx.size(), 4, "" + fx);
+        assertEquals( ((PropertyAccess)fx.get(2)).getSelector(), "ips" );
+
+        fx = (List) new DslParser("$brooklyn:object(\"[brooklyn.obj.TestObject,host]\").attributeWhenReady(\"ips_container\").a.b[0].c.d[1]").parse();
+        assertEquals(fx.size(), 8, "" + fx);
+        assertEquals( ((PropertyAccess)fx.get(3)).getSelector(), "b" );
+        assertEquals( ((PropertyAccess)fx.get(4)).getSelector(), "0" );
+        assertEquals( ((PropertyAccess)fx.get(6)).getSelector(), "d" );
+        assertEquals( ((PropertyAccess)fx.get(7)).getSelector(), "1" );
+    }
+
+    @Test
+    public void testParseFunctionExplicit() {
+        List fx = (List) new DslParser("$brooklyn:function.foo()").parse();
+        assertEquals( ((FunctionWithArgs)fx.get(0)).getFunction(), "$brooklyn:function.foo" );
+        assertEquals( ((FunctionWithArgs)fx.get(0)).getArgs(), ImmutableList.of() );
     }
 
 }
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformJoin.java b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformJoin.java
new file mode 100644
index 0000000..b14e8b7
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformJoin.java
@@ -0,0 +1,60 @@
+/*
+ * 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.steps.variables;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.brooklyn.core.workflow.WorkflowExpressionResolution;
+import org.apache.brooklyn.util.collections.MutableList;
+import org.apache.brooklyn.util.javalang.Boxing;
+import org.apache.brooklyn.util.text.Strings;
+
+public class TransformJoin extends WorkflowTransformDefault {
+
+    String separator;
+
+    @Override
+    protected void initCheckingDefinition() {
+        List<String> d = MutableList.copyOf(definition.subList(1, definition.size()));
+        if (d.size()>1) throw new IllegalArgumentException("Transform requires zero or one arguments being a token to insert between elements");
+        if (!d.isEmpty()) separator = d.get(0);
+    }
+
+    @Override
+    public Object apply(Object v) {
+        Object separatorResolvedO = separator==null ? "" : context.resolve(WorkflowExpressionResolution.WorkflowExpressionStage.STEP_RUNNING, separator, Object.class);
+        if (!(separatorResolvedO instanceof String || Boxing.isPrimitiveOrBoxedObject(separatorResolvedO))) {
+            throw new IllegalStateException("Argument must be a string or primitive to use as the separator");
+        }
+        String separatorResolved = ""+separatorResolvedO;
+        if (v instanceof Iterable) {
+            List list = MutableList.copyOf((Iterable)v);
+            return list.stream().map(x -> {
+                if (!(x instanceof String || Boxing.isPrimitiveOrBoxedObject(x))) {
+                    throw new IllegalStateException("Elements in the list to join must be a strings or primitives");
+                }
+                return ""+x;
+            }).collect(Collectors.joining(separatorResolved));
+        } else {
+            throw new IllegalStateException("Input must be a list to join");
+        }
+    }
+
+}
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformSlice.java b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformSlice.java
index f8b357a..d77d965 100644
--- a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformSlice.java
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformSlice.java
@@ -71,7 +71,7 @@
         return list.subList(from, to);
     }
 
-    static <T> T resolveAs(Object expression, WorkflowExecutionContext context, String errorPrefix, boolean failIfNull, Class<T> type, String typeName) {
+    public static <T> T resolveAs(Object expression, WorkflowExecutionContext context, String errorPrefix, boolean failIfNull, Class<T> type, String typeName) {
         T result = null;
         try {
             if (expression!=null) result = context.resolve(WorkflowExpressionResolution.WorkflowExpressionStage.STEP_RUNNING, expression, type);
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
new file mode 100644
index 0000000..d5b1779
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformSplit.java
@@ -0,0 +1,129 @@
+/*
+ * 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.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 {
+
+    String SHORTHAND = "\"split\" [ \"limit\" ${limit} ] [ ?${keep_delimiters} \"keep_delimiters\" ] [ ?${literal} \"literal\" ] [ ?${regex} \"regex\" ] ${delimiter}";
+
+    Integer limit;
+    String delimiter;
+    boolean keep_delimiters, literal, regex;
+
+    @Override
+    protected void initCheckingDefinition() {
+        Maybe<Map<String, Object>> maybeResult = new ShorthandProcessor(SHORTHAND)
+                .withFinalMatchRaw(false)
+                .withFailOnMismatch(true)
+                .process(transformDef);
+
+        if (maybeResult.isPresent()) {
+            Map<String, Object> result = maybeResult.get();
+            keep_delimiters = Boolean.TRUE.equals(result.get("keep_delimiters"));
+            literal = Boolean.TRUE.equals(result.get("literal"));
+            regex = Boolean.TRUE.equals(result.get("regex"));
+            limit = TransformSlice.resolveAs(result.get("limit"), context, "First argument 'limit'", false, Integer.class, "an integer");
+            delimiter = TransformSlice.resolveAs(result.get("delimiter"), context, "Last argument 'delimiter'", true, String.class, "a string");
+
+            // could disallow this, but it makes sense and works so we allow it;
+            //if (Strings.isEmpty(delimiter)) throw new IllegalArgumentException("Delimiter to split must not be empty");
+
+            if (regex && literal) throw new IllegalArgumentException("Only one of regex and literal can be set");
+            if (!regex && !literal) literal = true;
+        } else {
+            throw new IllegalArgumentException("Expression must be of the form 'split [limit L] [keep_delimiters] [literal|regex] DELIMITER");
+        }
+    }
+
+    @Override
+    public Object apply(Object v) {
+        if (v instanceof String) {
+            List<String> split = MutableList.of();
+
+            final String s = (String)v;
+            Matcher m = regex ? Pattern.compile(delimiter).matcher((String) v) : null;
+
+            int lastEnd = 0;
+            while (true) {
+                if (m==null) {
+                    int index = s.indexOf(delimiter, lastEnd);
+
+                    if (delimiter.isEmpty()) {
+                        if (split.isEmpty()) {
+                            split.add("");
+                            if (s.isEmpty()) break;
+                            if (keep_delimiters) split.add("");
+                        }
+                        index++;
+                    }
+
+                    if (index >= 0  && index<=s.length() && !s.isEmpty()) {
+                        split.add(s.substring(lastEnd, index));
+                        if (keep_delimiters) split.add(delimiter);
+                        lastEnd = index + delimiter.length();
+                    } else {
+                        split.add(s.substring(lastEnd));
+                        break;
+                    }
+                } else {
+                    if (m.find() && !s.isEmpty()) {
+                        if (m.start()<lastEnd) continue;
+                        if (lastEnd==m.end() && !split.isEmpty()) {
+                            // Matcher.find should increment, so this shouldn't happen, but double check;
+                            // we do match at start and end, deliberately
+                            throw new IllegalStateException("Regex match repeats splitting on empty string at same position");
+                        }
+                        split.add(s.substring(lastEnd, m.start()));
+                        if (keep_delimiters) split.add(s.substring(m.start(), m.end()));
+                        lastEnd = m.end();
+                    } else {
+                        split.add(s.substring(lastEnd));
+                        break;
+                    }
+                }
+                if (limit!=null && split.size() >= limit) {
+                    split = split.subList(0, limit);
+                    break;
+                }
+            }
+            return split;
+
+        } else {
+            throw new IllegalStateException("Input must be a string to split");
+        }
+    }
+
+}
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformTrim.java b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformTrim.java
index 7a38f6c..bfb3a84 100644
--- a/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformTrim.java
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/steps/variables/TransformTrim.java
@@ -30,10 +30,9 @@
     public Object apply(Object v) {
         if (v == null) return null;
         if (v instanceof String) return ((String) v).trim();
-        if (v instanceof Set) return ((Set) v).stream().filter(x -> x != null).collect(Collectors.toSet());
+        if (v instanceof Set) return ((Set) v).stream().filter(TransformTrim::shouldTrimKeepInList).collect(Collectors.toSet());
         if (v instanceof Collection)
-            return ((Collection) v).stream().filter(x -> x != null).collect(Collectors.toList());
-        ;
+            return ((Collection) v).stream().filter(TransformTrim::shouldTrimKeepInList).collect(Collectors.toList());
         if (v instanceof Map) {
             Map<Object, Object> result = MutableMap.of();
             ((Map) v).forEach((k, vi) -> {
@@ -43,4 +42,11 @@
         }
         return v;
     }
+
+    public static boolean shouldTrimKeepInList(Object x) {
+        if (x==null) return false;
+        if (x instanceof String) return !((String) x).isEmpty();
+        return true;
+    }
+
 }
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 86bbb0f..656e067 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
@@ -272,6 +272,8 @@
         TRANSFORMATIONS.put("merge", () -> new TransformMerge());
         TRANSFORMATIONS.put("prepend", () -> new TransformPrependAppend(false));
         TRANSFORMATIONS.put("append", () -> new TransformPrependAppend(true));
+        TRANSFORMATIONS.put("join", () -> new TransformJoin());
+        TRANSFORMATIONS.put("split", () -> new TransformSplit());
         TRANSFORMATIONS.put("slice", () -> new TransformSlice());
         TRANSFORMATIONS.put("remove", () -> new TransformRemove());
         TRANSFORMATIONS.put("json", () -> new TransformJsonish(true, false, false));
@@ -300,17 +302,18 @@
         TRANSFORMATIONS.put("sum", () -> v -> sum(v, "sum"));
         TRANSFORMATIONS.put("average", () -> v -> average(v, "average"));
         TRANSFORMATIONS.put("size", () -> v -> size(v, "size"));
-        TRANSFORMATIONS.put("get", () -> v -> {
-            // TODO should this be able to get indexes etc
-            if (v instanceof Supplier) return ((Supplier)v).get();
-            return v;
-        });
         TRANSFORMATIONS.put("to_string", () -> v -> Strings.toString(v));
         TRANSFORMATIONS.put("to_upper_case", () -> v -> ((String)v).toUpperCase());
         TRANSFORMATIONS.put("to_lower_case", () -> v -> ((String)v).toLowerCase());
         TRANSFORMATIONS.put("return", () -> new TransformReturn());
         TRANSFORMATIONS.put("set", () -> new TransformSetWorkflowVariable());
         TRANSFORMATIONS.put("resolve_expression", () -> new TransformResolveExpression());
+
+        // 'get' is added downstream when DSL is initialized
+        //TRANSFORMATIONS.put("get", () -> new TransformGet());
+    }
+    public static void registerTransformation(String key, Supplier<Function> xform) {
+        TRANSFORMATIONS.put(key, xform);
     }
 
     static final Object minmax(Object v, String word, Predicate<Integer> test) {
diff --git a/core/src/test/java/org/apache/brooklyn/core/workflow/WorkflowTransformTest.java b/core/src/test/java/org/apache/brooklyn/core/workflow/WorkflowTransformTest.java
index af4746c..4a29156 100644
--- a/core/src/test/java/org/apache/brooklyn/core/workflow/WorkflowTransformTest.java
+++ b/core/src/test/java/org/apache/brooklyn/core/workflow/WorkflowTransformTest.java
@@ -18,11 +18,16 @@
  */
 package org.apache.brooklyn.core.workflow;
 
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import com.google.common.base.Suppliers;
 import org.apache.brooklyn.api.entity.Entity;
-import org.apache.brooklyn.api.entity.EntityLocal;
 import org.apache.brooklyn.api.entity.EntitySpec;
-import org.apache.brooklyn.api.mgmt.ManagementContext;
-import org.apache.brooklyn.api.mgmt.Task;
+import org.apache.brooklyn.core.config.ConfigKeys;
 import org.apache.brooklyn.core.entity.Entities;
 import org.apache.brooklyn.core.test.BrooklynMgmtUnitTestSupport;
 import org.apache.brooklyn.core.workflow.steps.variables.TransformVariableWorkflowStep;
@@ -30,17 +35,11 @@
 import org.apache.brooklyn.test.Asserts;
 import org.apache.brooklyn.util.collections.MutableList;
 import org.apache.brooklyn.util.collections.MutableMap;
-import org.apache.brooklyn.util.core.config.ConfigBag;
 import org.apache.brooklyn.util.text.Strings;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
-import java.util.Arrays;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
 public class WorkflowTransformTest extends BrooklynMgmtUnitTestSupport {
 
     protected void loadTypes() {
@@ -65,7 +64,7 @@
     }
 
     static Object runWorkflowSteps(Entity entity, String ...steps) {
-        return WorkflowBasicTest.runWorkflow(entity, Arrays.asList(steps).stream().map(s -> "- "+Strings.indent(2, s).trim()).collect(Collectors.joining("\n")), "test").getTask(false).get().getUnchecked();
+        return WorkflowBasicTest.runWorkflow(entity, Arrays.stream(steps).map(s -> "- "+Strings.indent(2, s).trim()).collect(Collectors.joining("\n")), "test").getTask(false).get().getUnchecked();
     }
     Object runWorkflowSteps(String ...steps) {
         return runWorkflowSteps(app, steps);
@@ -119,7 +118,7 @@
 
 
     @Test
-    public void testTransformTrim() throws Exception {
+    public void testTransformTrim() {
         String untrimmed = "Hello, World!   ";
         String trimmed = untrimmed.trim();
 
@@ -132,7 +131,7 @@
     }
 
     @Test
-    public void testTransformRegex() throws Exception {
+    public void testTransformRegex() {
         Asserts.assertEquals(transform("value 'silly world' | replace regex l. k"), "siky world");
         Asserts.assertEquals(transform("value 'silly world' | replace all regex l. k"), "siky work");
         // with slash
@@ -151,14 +150,14 @@
     }
 
     @Test
-    public void testTransformLiteral() throws Exception {
+    public void testTransformLiteral() {
         Asserts.assertEquals(transform("value 'abc def ghi' | replace literal c.*d XXX"), "abc def ghi");
         Asserts.assertEquals(transform("value 'abc.*def ghi c.*d' | replace literal c.*d XXX"), "abXXXef ghi c.*d");
         Asserts.assertEquals(transform("value 'abc.*def ghi c.*d' | replace all literal c.*d XXX"), "abXXXef ghi XXX");
     }
 
     @Test
-    public void testTransformGlob() throws Exception {
+    public void testTransformGlob() {
         Asserts.assertEquals(transform("value 'abc def ghi' | replace glob c*e XXX"), "abXXXf ghi");
         // glob is greedy, unless all is specified where it is not
         Asserts.assertEquals(transform("value 'abc def ghi c2e' | replace glob c*e XXX"), "abXXX");
@@ -275,4 +274,58 @@
                 MutableMap.of("a", 1));
     }
 
+    @Test
+    public void testTransformJoin() {
+        Asserts.assertEquals( runWorkflowSteps(
+                        "let list words = [ \"hello\" , 1, \"world\" ]",
+                        "transform ${words} | join \" \" | return"),
+                "hello 1 world");
+    }
+
+    @Test
+    public void testTransformSplit() {
+        BiFunction<String,String,Object> split = (input, command) -> runWorkflowSteps(
+                            "let words = "+input,
+                            "transform ${words} | split "+command);
+
+        Asserts.assertEquals( split.apply("\"hello 1 world\"", "\" \""), MutableList.of("hello", "1", "world"));
+        Asserts.assertEquals( split.apply("\"hello  1 world\"", "\"  \""), MutableList.of("hello", "1 world"));
+        Asserts.assertEquals( split.apply("\"hello  1 world\"", "literal \"  \""), MutableList.of("hello", "1 world"));
+        Asserts.assertEquals( split.apply("\"hello  1 world\"", "regex \" +\""), MutableList.of("hello", "1", "world"));
+        Asserts.assertEquals( split.apply("\"hello  1 world\"", "\" +\""), MutableList.of("hello  1 world"));
+        Asserts.assertEquals( split.apply("\"hello  1 world\"", "keep_delimiters regex \" +\""), MutableList.of("hello", "  ", "1", " ", "world"));
+        Asserts.assertEquals( split.apply("\"hello  1 world\"", "limit 4 keep_delimiters regex \" +\""), MutableList.of("hello", "  ", "1", " "));
+        Asserts.assertEquals( split.apply("\"hello  1 world\"", "keep_delimiters \" \""), MutableList.of("hello", " ", "", " ", "1", " ", "world"));
+        Asserts.assertEquals( split.apply("\"hello  1 world\"", "keep_delimiters regex \" \""), MutableList.of("hello", " ", "", " ", "1", " ", "world"));
+
+        Asserts.assertEquals( split.apply("\"hello  1 world\"", "\" \""), MutableList.of("hello", "", "1", "world"));
+        Asserts.assertEquals( split.apply("\"  hello  1 world \"", "\" \" | trim"), MutableList.of("hello", "1", "world"));
+
+        // leading and trailing matches generate an empty string in the output list
+        Asserts.assertEquals( split.apply("\" h ey \"", "\" \""), MutableList.of("", "h", "ey", ""));
+        Asserts.assertEquals( split.apply("\" h ey \"", "regex \" \""), MutableList.of("", "h", "ey", ""));
+
+        // empty string matches every break, including start and end, once
+        Asserts.assertEquals( split.apply("\"hey\"", "regex \"\""), MutableList.of("", "h", "e", "y", ""));
+        Asserts.assertEquals( split.apply("\"hey\"", "\"\""), MutableList.of("", "h", "e", "y", ""));
+        Asserts.assertEquals( split.apply("\"\"", "regex \"\""), MutableList.of(""));
+        Asserts.assertEquals( split.apply("\"\"", "\"\""), MutableList.of(""));
+    }
+
+    @Test
+    public void testTransformSum() {
+        Asserts.assertEquals( runWorkflowSteps(
+                        "let list nums = [ 1, 2, 3]",
+                        "transform ${nums} | sum | return"),
+                6);
+
+        Asserts.assertFailsWith( () -> runWorkflowSteps(
+                        "let list nums = [ 1, 2, 3]",
+                        "transform ${nums} | sum 4 | return"),
+                e -> Asserts.expectedFailureContainsIgnoreCase(e, "sum", "does not accept args", "4"));
+    }
+
+    // done in camp project, WorkflowExpressionsYamlTest
+    //public void testTransformGet() {
+
 }