SLING-9986 scripting improvements

- add possibility to use dates,
- rework internals of command tokenization for broader acceptation, and quoting of sub tokens,
- add a purge service
diff --git a/src/main/java/org/apache/sling/pipes/PipeBindings.java b/src/main/java/org/apache/sling/pipes/PipeBindings.java
index 789e1cb..52c9c1d 100644
--- a/src/main/java/org/apache/sling/pipes/PipeBindings.java
+++ b/src/main/java/org/apache/sling/pipes/PipeBindings.java
@@ -17,6 +17,7 @@
 package org.apache.sling.pipes;
 
 import org.apache.commons.io.IOUtils;
+import org.apache.commons.jexl3.JexlException;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
@@ -232,7 +233,7 @@
             if (computed != null) {
                 return getEngine() != null ? engine.eval(computed, scriptContext) : internalEvaluate(computed);
             }
-        } catch (ScriptException e) {
+        } catch (ScriptException | JexlException e) {
             throw new IllegalArgumentException(e);
         }
         return expr;
diff --git a/src/main/java/org/apache/sling/pipes/Plumber.java b/src/main/java/org/apache/sling/pipes/Plumber.java
index 097539c..c625c6c 100644
--- a/src/main/java/org/apache/sling/pipes/Plumber.java
+++ b/src/main/java/org/apache/sling/pipes/Plumber.java
@@ -168,4 +168,9 @@
      * @return referenced resource, null otherwise
      */
     @Nullable Resource getReferencedResource(Resource referrer, String reference);
+
+    /*
+     * Generates unique pipe path for persistence sake
+     */
+    String generateUniquePath();
 }
diff --git a/src/main/java/org/apache/sling/pipes/internal/CommandExecutorImpl.java b/src/main/java/org/apache/sling/pipes/internal/CommandExecutorImpl.java
index b877b00..b1858bf 100644
--- a/src/main/java/org/apache/sling/pipes/internal/CommandExecutorImpl.java
+++ b/src/main/java/org/apache/sling/pipes/internal/CommandExecutorImpl.java
@@ -60,6 +60,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_ACCEPTABLE;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
+import static org.apache.commons.lang3.StringUtils.EMPTY;
 import static org.apache.sling.pipes.internal.CommandUtil.keyValuesToArray;
 import static org.apache.sling.pipes.internal.CommandUtil.writeToMap;
 
@@ -82,8 +83,11 @@
     static final String WHITE_SPACE_SEPARATOR = "\\s";
     static final String COMMENT_PREFIX = "#";
     static final String SEPARATOR = "|";
+    static final String PIPE_SEPARATOR = WHITE_SPACE_SEPARATOR + "*\\" + SEPARATOR + WHITE_SPACE_SEPARATOR + "*";
     static final String LINE_SEPARATOR = " ";
     static final String PARAMS = "@";
+    static final String PARAMS_SEPARATOR = WHITE_SPACE_SEPARATOR + "+" + PARAMS + WHITE_SPACE_SEPARATOR + "*";
+    static final Pattern SUB_TOKEN_PATTERN = Pattern.compile("(([^\"]\\S*)|\"([^\"]+)\")\\s*");
     static final String KEY_NAME = "name";
     static final String KEY_PATH = "path";
     static final String KEY_EXPR = "expr";
@@ -171,7 +175,7 @@
                 for (String command : cmds) {
                     if (StringUtils.isNotBlank(command)) {
                         currentCommand = command;
-                        PipeBuilder pipeBuilder = parse(resolver, command.split(WHITE_SPACE_SEPARATOR));
+                        PipeBuilder pipeBuilder = parse(resolver, command);
                         Pipe pipe = pipeBuilder.build();
                         bindings.put(CMD_LINE_PREFIX + idxLine++, pipe.getResource().getPath());
                         plumber.execute(resolver, pipe, bindings, pipeWriter, true);
@@ -247,21 +251,6 @@
     }
 
     /**
-     * ends up processing of current token
-     * @param currentToken token being processed
-     * @param currentList list of argument that have been collected so far
-     */
-    protected void finishToken(Token currentToken, List<String> currentList){
-        if (currentToken.args != null){
-            //it means we have already parse args here, so we need to set current list as options
-            currentToken.options = getOptions(currentList);
-        } else {
-            currentToken.args = currentList;
-        }
-        log.debug("current token : {}", currentToken);
-    }
-
-    /**
      * @param tokens array of tokens
      * @return options from array
      */
@@ -314,22 +303,18 @@
          */
         protected Options(List<String> options){
             Map<String, Object> optionMap = new HashMap<>();
-            String currentKey = null;
-            List<String> currentList = null;
-
-
             for (String optionToken : options) {
-                if (PARAMS.equals(optionToken)){
-                    finishOption(currentKey, currentList, optionMap);
-                    currentList = new ArrayList<>();
-                    currentKey = null;
-                } else if (currentKey == null){
-                    currentKey = optionToken;
-                } else {
-                    currentList.add(optionToken);
+                String currentKey = null;
+                List<String> currentList = new ArrayList<>();
+                for (String subToken : getSpaceSeparatedTokens(optionToken)) {
+                    if (currentKey == null) {
+                        currentKey = subToken;
+                    } else {
+                        currentList.add(subToken);
+                    }
                 }
+                finishOption(currentKey, currentList, optionMap);
             }
-            finishOption(currentKey, currentList, optionMap);
             for (Map.Entry<String, Object> entry : optionMap.entrySet()){
                 switch (entry.getKey()) {
                     case Pipe.PN_NAME :
@@ -354,7 +339,6 @@
                         throw new IllegalArgumentException(String.format("%s is an unknown option", entry.getKey()));
                 }
             }
-
         }
 
         /**
@@ -415,39 +399,38 @@
         }
     }
 
+    List<String> getSpaceSeparatedTokens(String token) {
+        List<String> subTokens = new ArrayList<>();
+        Matcher matcher = SUB_TOKEN_PATTERN.matcher(token);
+        while (matcher.find()){
+            subTokens.add(matcher.group(2) != null ? matcher.group(2) : matcher.group(3));
+        }
+        return subTokens;
+    }
+
     /**
      * @param commands full list of command tokens
      * @return Token list corresponding to the string ones
      */
     protected List<CommandExecutorImpl.Token> parseTokens(String... commands) {
         List<CommandExecutorImpl.Token> returnValue = new ArrayList<>();
-        CommandExecutorImpl.Token currentToken = new CommandExecutorImpl.Token();
-        returnValue.add(currentToken);
-        List<String> currentList = new ArrayList<>();
-        for (String token : commands){
-            if (currentToken.pipeKey == null){
-                currentToken.pipeKey = token;
-            } else {
-                switch (token){
-                    case CommandExecutorImpl.SEPARATOR:
-                        finishToken(currentToken, currentList);
-                        currentList = new ArrayList<>();
-                        currentToken = new CommandExecutorImpl.Token();
-                        returnValue.add(currentToken);
-                        break;
-                    case CommandExecutorImpl.PARAMS:
-                        if (currentToken.args == null){
-                            currentToken.args = currentList;
-                            currentList = new ArrayList<>();
-                        }
-                        currentList.add(PARAMS);
-                        break;
-                    default:
-                        currentList.add(token);
+        String cat = String.join(EMPTY, commands);
+        for (String token : cat.split(PIPE_SEPARATOR)){
+            CommandExecutorImpl.Token currentToken = new CommandExecutorImpl.Token();
+            String[] options = token.split(PARAMS_SEPARATOR);
+            if (options.length > 1) {
+                currentToken.options = getOptions(Arrays.copyOfRange(options, 1, options.length));
+            }
+            List<String> subTokens = getSpaceSeparatedTokens(options[0]);
+            if (subTokens.size() > 0) {
+                currentToken.pipeKey = subTokens.get(0);
+                if (subTokens.size() > 1) {
+                    currentToken.args = subTokens.subList(1, subTokens.size());
                 }
             }
+            log.trace("generated following token {}", currentToken);
+            returnValue.add(currentToken);
         }
-        finishToken(currentToken, currentList);
         return returnValue;
     }
 
diff --git a/src/main/java/org/apache/sling/pipes/internal/CommandUtil.java b/src/main/java/org/apache/sling/pipes/internal/CommandUtil.java
index 40f0c68..c21004c 100644
--- a/src/main/java/org/apache/sling/pipes/internal/CommandUtil.java
+++ b/src/main/java/org/apache/sling/pipes/internal/CommandUtil.java
@@ -38,11 +38,12 @@
     static final String FIRST_TOKEN = "first";
     static final String SECOND_TOKEN = "second";
     private static final Pattern UNEMBEDDEDSCRIPT_PATTERN = Pattern.compile("^(\\d+(\\.\\d+)?)|" + //21.42
-            "(\\[.*])|" + //['one','two']
-            "(\\w[\\w_\\-\\d]+\\.[\\w_\\-\\d]+)|" + //map.field
+            "(\\[.*]$)|" + //['one','two']
+            "(\\w[\\w_\\-\\d]+\\..+)|" + //map.field...
             "(\\w[\\w_\\-\\d]+\\['.+'])|" + //map['field']
-            "(true|false)|" + //boolean
-            "'.*'$"); // 'string'
+            "(true$|false$)|" + //boolean
+            "(new .*)|" + //instantiation
+            "(.*'$)"); // 'string'
     static final String CONFIGURATION_TOKEN = "(?<" + FIRST_TOKEN + ">[\\w/\\:]+)\\s*" + KEY_VALUE_SEP
             + "(?<" + SECOND_TOKEN + ">[(\\w*)|" + INJECTED_SCRIPT_REGEXP + "]+)";
     public static final Pattern CONFIGURATION_PATTERN = Pattern.compile(CONFIGURATION_TOKEN);
@@ -109,11 +110,13 @@
      */
     public static String[] keyValuesToArray(List<String> o) {
         List<String> args = new ArrayList<>();
-        for (String pair : o){
-            Matcher matcher = CONFIGURATION_PATTERN.matcher(pair.trim());
-            if (matcher.matches()) {
-                args.add(matcher.group(FIRST_TOKEN));
-                args.add(matcher.group(SECOND_TOKEN));
+        if (o != null) {
+            for (String pair : o) {
+                Matcher matcher = CONFIGURATION_PATTERN.matcher(pair.trim());
+                if (matcher.matches()) {
+                    args.add(matcher.group(FIRST_TOKEN));
+                    args.add(matcher.group(SECOND_TOKEN));
+                }
             }
         }
         return args.toArray(new String[args.size()]);
diff --git a/src/main/java/org/apache/sling/pipes/internal/JsonWriter.java b/src/main/java/org/apache/sling/pipes/internal/JsonWriter.java
index e5d3d3f..37d7f11 100644
--- a/src/main/java/org/apache/sling/pipes/internal/JsonWriter.java
+++ b/src/main/java/org/apache/sling/pipes/internal/JsonWriter.java
@@ -71,9 +71,8 @@
             jsonGenerator.writeStartObject();
             jsonGenerator.write(PATH_KEY, resource.getPath());
             for (Map.Entry<String, Object> entry : customOutputs.entrySet()) {
-                Object o = null;
                 try {
-                    o = pipe.getBindings().instantiateObject((String) entry.getValue());
+                    Object o = pipe.getBindings().instantiateObject((String) entry.getValue());
                     if (o instanceof JsonValue) {
                         jsonGenerator.write(entry.getKey(), (JsonValue) o);
                     } else {
@@ -81,7 +80,7 @@
                     }
                 } catch (RuntimeException e) {
                     LOGGER.error("unable to write entry {}, will write empty value", entry, e);
-                    jsonGenerator.write(StringUtils.EMPTY);
+                    jsonGenerator.write(entry.getKey(), StringUtils.EMPTY);
                 }
             }
             jsonGenerator.writeEnd();
diff --git a/src/main/java/org/apache/sling/pipes/internal/PipeBuilderImpl.java b/src/main/java/org/apache/sling/pipes/internal/PipeBuilderImpl.java
index d272faa..b4d8196 100644
--- a/src/main/java/org/apache/sling/pipes/internal/PipeBuilderImpl.java
+++ b/src/main/java/org/apache/sling/pipes/internal/PipeBuilderImpl.java
@@ -43,12 +43,10 @@
 import org.slf4j.LoggerFactory;
 
 import java.util.ArrayList;
-import java.util.Calendar;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.UUID;
 
 import static org.apache.commons.lang3.StringUtils.EMPTY;
 import static org.apache.sling.jcr.resource.JcrResourceConstants.NT_SLING_FOLDER;
@@ -64,8 +62,6 @@
 public class PipeBuilderImpl implements PipeBuilder {
     private static final Logger logger = LoggerFactory.getLogger(PipeBuilderImpl.class);
 
-    public static final String PIPES_REPOSITORY_PATH = "/var/pipes";
-
     private static final String[] DEFAULT_NAMES = new String[]{"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"};
 
     List<Step> steps;
@@ -319,16 +315,6 @@
     }
 
     /**
-     * build a time + random based path under /var/pipes
-     * @return full path of future Pipe
-     */
-    String buildRandomPipePath() {
-        final Calendar now = Calendar.getInstance();
-        return PIPES_REPOSITORY_PATH + '/' + now.get(Calendar.YEAR) + '/' + now.get(Calendar.MONTH) + '/' + now.get(Calendar.DAY_OF_MONTH) + "/"
-                + UUID.randomUUID().toString();
-    }
-
-    /**
      * Create a configuration resource
      * @param resolver current resolver
      * @param path path of the resource
@@ -364,7 +350,7 @@
 
     @Override
     public Pipe build() throws PersistenceException {
-        return build(buildRandomPipePath());
+        return build(plumber.generateUniquePath());
     }
 
     /**
diff --git a/src/main/java/org/apache/sling/pipes/internal/PlumberImpl.java b/src/main/java/org/apache/sling/pipes/internal/PlumberImpl.java
index dd9eacd..f51155a 100644
--- a/src/main/java/org/apache/sling/pipes/internal/PlumberImpl.java
+++ b/src/main/java/org/apache/sling/pipes/internal/PlumberImpl.java
@@ -21,6 +21,7 @@
 import org.apache.sling.api.SlingConstants;
 import org.apache.sling.api.SlingHttpServletRequest;
 import org.apache.sling.api.request.RequestParameter;
+import org.apache.sling.api.resource.AbstractResourceVisitor;
 import org.apache.sling.api.resource.LoginException;
 import org.apache.sling.api.resource.ModifiableValueMap;
 import org.apache.sling.api.resource.PersistenceException;
@@ -69,6 +70,8 @@
 import java.lang.management.ManagementFactory;
 import java.lang.reflect.Method;
 import java.security.AccessControlException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Calendar;
@@ -80,6 +83,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 
 import static org.apache.sling.api.resource.ResourceResolverFactory.SUBSERVICE;
 import static org.apache.sling.pipes.BasePipe.PN_STATUS;
@@ -91,11 +95,11 @@
 /**
  * implements plumber interface, registers default pipes, and provides execution facilities
  */
-@Component(service = {Plumber.class, JobConsumer.class}, property = {
+@Component(service = {Plumber.class, JobConsumer.class, PlumberMXBean.class, Runnable.class}, property = {
         JobConsumer.PROPERTY_TOPICS +"="+PlumberImpl.SLING_EVENT_TOPIC
 })
 @Designate(ocd = PlumberImpl.Configuration.class)
-public class PlumberImpl implements Plumber, JobConsumer, PlumberMXBean {
+public class PlumberImpl implements Plumber, JobConsumer, PlumberMXBean, Runnable {
     private final Logger log = LoggerFactory.getLogger(this.getClass());
     public static final int DEFAULT_BUFFER_SIZE = 1000;
 
@@ -110,6 +114,8 @@
 
     static final String PERMISSION_EXECUTION = "/system/sling/permissions/pipes/exec";
 
+    public static final String PIPES_REPOSITORY_PATH = "/var/pipes";
+
     @ObjectClassDefinition(name="Apache Sling Pipes : Plumber configuration")
     public @interface Configuration {
         @AttributeDefinition(description="Number of iterations after which plumber should saves a pipe execution")
@@ -129,6 +135,12 @@
 
         @AttributeDefinition(description = "Paths to search for references in")
         String[] referencesPaths() default {};
+
+        @AttributeDefinition(description = "max age (in days) of automatically generated pipe persistence")
+        int maxAge() default 31;
+
+        @AttributeDefinition(description = "schedule of purge process")
+        String scheduler_expression() default "0 0 12 */7 * ?";
     }
 
     @Reference(policy= ReferencePolicy.DYNAMIC, cardinality= ReferenceCardinality.OPTIONAL)
@@ -545,4 +557,60 @@
         }
         return beans;
     }
+
+    /**
+     * build a time + random based path under /var/pipes
+     * @return full path of future Pipe
+     */
+    public String generateUniquePath() {
+        final Calendar now = Calendar.getInstance();
+        return PIPES_REPOSITORY_PATH + '/' + now.get(Calendar.YEAR) + '/' + now.get(Calendar.MONTH) + '/' + now.get(Calendar.DAY_OF_MONTH) + "/"
+                + UUID.randomUUID().toString();
+    }
+
+    void cleanResourceAndEmptyParents(Resource resource) throws PersistenceException {
+        log.debug("starting removal of {}", resource);
+        Resource parent = resource.getParent();
+        resource.getResourceResolver().delete(resource);
+        if (!parent.hasChildren() && !PIPES_REPOSITORY_PATH.equals(parent.getPath())) {
+            cleanResourceAndEmptyParents(parent);
+        }
+    }
+
+    void purge(ResourceResolver resolver, Instant now, int maxDays) throws PersistenceException {
+        final Collection<String> pipesToRemove = new ArrayList<>();
+        AbstractResourceVisitor visitor = new AbstractResourceVisitor() {
+            @Override
+            protected void visit(Resource res) {
+                Calendar cal = res.getValueMap().get(PN_STATUS_MODIFIED, Calendar.class);
+                if (cal != null && ChronoUnit.DAYS.between(cal.toInstant(), now) > maxDays) {
+                    pipesToRemove.add(res.getPath());
+                }
+            }
+        };
+        visitor.accept(resolver.getResource(PIPES_REPOSITORY_PATH));
+        if (pipesToRemove.size() > 0) {
+            log.info("about to remove {} pipe instances", pipesToRemove.size());
+            for (String path : pipesToRemove) {
+                cleanResourceAndEmptyParents(resolver.getResource(path));
+            }
+            resolver.commit();
+            log.info("purge done.");
+        }
+    }
+
+    @Override
+    public void run() {
+        if (serviceUser == null) {
+            log.warn("no service user configured, will not be able to purge old pipe instances");
+        } else {
+            try (ResourceResolver resolver = factory.getServiceResourceResolver(serviceUser)) {
+                log.info("Starting pipe purge based on a max age of {} days", configuration.maxAge());
+                purge(resolver, Instant.now(), configuration.maxAge());
+                resolver.commit();
+            } catch (LoginException | PersistenceException e) {
+                log.error("unable purge {}", PIPES_REPOSITORY_PATH, e);
+            }
+        }
+    }
 }
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/pipes/internal/bindings/JxltEngine.java b/src/main/java/org/apache/sling/pipes/internal/bindings/JxltEngine.java
index ff84801..220d801 100644
--- a/src/main/java/org/apache/sling/pipes/internal/bindings/JxltEngine.java
+++ b/src/main/java/org/apache/sling/pipes/internal/bindings/JxltEngine.java
@@ -16,6 +16,8 @@
  */
 package org.apache.sling.pipes.internal.bindings;
 
+import java.text.DateFormat;
+import java.util.Date;
 import java.util.Map;
 
 import org.apache.commons.jexl3.JexlBuilder;
@@ -28,10 +30,12 @@
 
     JexlEngine jexl;
     JexlContext jc;
+    static final String KEY_TIME = "timeutil";
 
     public JxltEngine(Map<String, Object> context) {
         jexl = new JexlBuilder().create();
         jc = new MapContext(context);
+        jc.set(KEY_TIME, new TimeUtil());
     }
 
     public Object parse(String expression) {
diff --git a/src/main/java/org/apache/sling/pipes/internal/bindings/TimeUtil.java b/src/main/java/org/apache/sling/pipes/internal/bindings/TimeUtil.java
new file mode 100644
index 0000000..22455e6
--- /dev/null
+++ b/src/main/java/org/apache/sling/pipes/internal/bindings/TimeUtil.java
@@ -0,0 +1,51 @@
+/*
+ * 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.sling.pipes.internal.bindings;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.Locale;
+
+/**
+ * Utility class to be used in bindings
+ */
+public class TimeUtil {
+
+    /**
+     * @param date string date following <code>ISO_LOCAL_DATE</code>
+     * @return Gregorian calendar impl of that time
+     */
+    public Calendar ofDate(String date) {
+        LocalDate localDate = LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE);
+        return GregorianCalendar.from(localDate.atStartOfDay(ZoneId.systemDefault()));
+    }
+
+    /**
+     * @param time string date following <code>ISO_DATE_TIME</code>
+     * @return Gregorian calendar impl of that time
+     */
+    public Calendar of(String time){
+        OffsetDateTime dt = OffsetDateTime.parse(time);
+        return GregorianCalendar.from(dt.toZonedDateTime());
+    }
+}
diff --git a/src/test/java/org/apache/sling/pipes/AbstractPipeTest.java b/src/test/java/org/apache/sling/pipes/AbstractPipeTest.java
index 711a07a..105ec2f 100644
--- a/src/test/java/org/apache/sling/pipes/AbstractPipeTest.java
+++ b/src/test/java/org/apache/sling/pipes/AbstractPipeTest.java
@@ -102,7 +102,7 @@
     }
 
     protected ExecutionResult execute(ResourceResolver resolver, String command) throws InvocationTargetException, IllegalAccessException {
-        PipeBuilder builder = commandsExecutor.parse(resolver, command.trim().split("\\s"));
+        PipeBuilder builder = commandsExecutor.parse(resolver, command);
         return builder.run();
     }
 
diff --git a/src/test/java/org/apache/sling/pipes/internal/CommandExecutorImplTest.java b/src/test/java/org/apache/sling/pipes/internal/CommandExecutorImplTest.java
index 7362286..85e44c9 100644
--- a/src/test/java/org/apache/sling/pipes/internal/CommandExecutorImplTest.java
+++ b/src/test/java/org/apache/sling/pipes/internal/CommandExecutorImplTest.java
@@ -38,6 +38,7 @@
 import java.util.Map;
 import java.util.stream.Collectors;
 
+import static org.apache.sling.pipes.internal.CommandExecutorImpl.PARAMS_SEPARATOR;
 import static org.apache.sling.pipes.internal.CommandUtil.keyValuesToArray;
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
@@ -60,20 +61,37 @@
     }
 
     @Test
-    public void testParseTokens(){
-        List<CommandExecutorImpl.Token> tokens = commands.parseTokens("some", "isolated", "items");
+    public void getSublist() {
+        assertArrayEquals(new String[]{"check", "this"}, commands.getSpaceSeparatedTokens("check this").toArray());
+        assertArrayEquals(new String[]{"and", "now","check this"}, commands.getSpaceSeparatedTokens("and now \"check this\"").toArray());
+    }
+
+        @Test
+    public void testParseTokens() {
+        List<CommandExecutorImpl.Token> tokens = commands.parseTokens("some isolated items");
         assertEquals("there should be 1 token", 1, tokens.size());
         CommandExecutorImpl.Token token = tokens.get(0);
-        assertEquals("pipe key should be 'some'","some", token.pipeKey);
-        assertEquals("pipe args should be isolated, items", Arrays.asList("isolated","items"), token.args);
+        assertEquals("pipe key should be 'some'", "some", token.pipeKey);
+        assertEquals("pipe args should be isolated, items", Arrays.asList("isolated", "items"), token.args);
+    }
+    @Test
+    public void testParseTokensWithQuotes() {
         String tokenString = "first arg | second firstarg secondarg @ name second | third blah";
-        tokens = commands.parseTokens(tokenString.split("\\s"));
+        List<CommandExecutorImpl.Token> tokens = commands.parseTokens(tokenString);
         assertEquals("there should be 3 tokens", 3, tokens.size());
         assertEquals("keys check", Arrays.asList("first","second", "third"), tokens.stream().map(t -> t.pipeKey).collect(Collectors.toList()));
         assertEquals("params check", "second", tokens.get(1).options.name);
     }
 
     @Test
+    public void testQuotedTokens() {
+        List<CommandExecutorImpl.Token> tokens = commands.parseTokens("some isolated items \"with quotes\"");
+        assertEquals("there should be 1 token", 1, tokens.size());
+        CommandExecutorImpl.Token token = tokens.get(0);
+        assertEquals("pipe args should be isolated, items", Arrays.asList("isolated", "items", "with quotes"), token.args);
+    }
+
+    @Test
     public void testKeyValueToArray() {
         assertArrayEquals(new String[]{"one","two","three","four"}, keyValuesToArray(Arrays.asList("one=two","three=four")));
         assertArrayEquals(new String[]{"one","two","three","${four}"}, keyValuesToArray(Arrays.asList("one=two","three=${four}")));
@@ -84,13 +102,13 @@
 
     @Test
     public void testSimpleExpression() throws Exception {
-        PipeBuilder builder = commands.parse(context.resourceResolver(),"echo","/content/fruits");
+        PipeBuilder builder = commands.parse(context.resourceResolver(),"echo /content/fruits");
         assertTrue("there should be a resource", builder.build().getOutput().hasNext());
     }
 
     @Test
     public void testSimpleChainedConf() throws Exception {
-        PipeBuilder builder = commands.parse(context.resourceResolver(),"echo /content/fruits | write some=test key=value".split("\\s"));
+        PipeBuilder builder = commands.parse(context.resourceResolver(),"echo /content/fruits | write some=test key=value");
         assertNotNull("there should be a resource", builder.run());
         ValueMap props = context.currentResource(PATH_FRUITS).getValueMap();
         assertEquals("there should some=test", "test", props.get("some"));
@@ -100,8 +118,8 @@
     @Test
     public void testOptions() {
         String expected = "works";
-        String optionString = "@ name works @ path works @ expr works @ with one=works two=works @ outputs one=works two=works";
-        CommandExecutorImpl.Options options = commands.getOptions(optionString.split("\\s"));
+        String optionString = "name works @ path works @ expr works @ with one=works two=works @ outputs one=works two=works";
+        CommandExecutorImpl.Options options = commands.getOptions(optionString.split(PARAMS_SEPARATOR));
         assertEquals("check name", expected, options.name);
         assertEquals("check expr", expected, options.expr);
         assertEquals("check path", expected, options.path);
@@ -118,8 +136,8 @@
     @Test
     public void testOptionsListsWithOneItem() {
         String expected = "works";
-        String optionString = "@ with one=works @ outputs one=works";
-        CommandExecutorImpl.Options options = commands.getOptions(optionString.split("\\s"));
+        String optionString = "with one=works @ outputs one=works";
+        CommandExecutorImpl.Options options = commands.getOptions(optionString.split(PARAMS_SEPARATOR));
         Map bindings = new HashMap();
         CommandUtil.writeToMap(bindings, true, options.with);
         assertEquals("check with first", expected, bindings.get("one"));
@@ -131,7 +149,7 @@
     @Test
     public void testChainedConfWithInternalOptions() throws Exception {
         PipeBuilder builder = commands.parse(context.resourceResolver(),
-        "echo /content/fruits @ name fruits | write some=${path.fruits} key=value".split("\\s"));
+        "echo /content/fruits @ name fruits | write some=${path.fruits} key=value");
         assertNotNull("there should be a resource", builder.run());
         ValueMap props = context.currentResource(PATH_FRUITS).getValueMap();
         assertEquals("there should some=/content/fruits", PATH_FRUITS, props.get("some"));
@@ -142,7 +160,7 @@
     public void adaptToDemoTest() throws Exception {
         String url = "'http://99-bottles-of-beer.net/lyrics.html'";
         String cmd = "egrep " + url + " @ name bottles @ with pattern=(?<number>\\d(\\d)?) | mkdir /var/bottles/${bottles.number}";
-        PipeBuilder builder = commands.parse(context.resourceResolver(), cmd.split("\\s"));
+        PipeBuilder builder = commands.parse(context.resourceResolver(), cmd);
         ContainerPipe pipe = (ContainerPipe)builder.build();
         ValueMap regexp = pipe.getResource().getChild("conf/bottles").getValueMap();
         assertEquals("we expect expr to be the url", url, regexp.get("expr"));
@@ -152,7 +170,8 @@
     public void testExecuteWithWriter() throws Exception {
         PipeBuilder builder = plumber.newPipe(context.resourceResolver()).echo("/content/${node}").$("nt:base");
         String path = builder.build().getResource().getPath();
-        ExecutionResult result = commands.execute(context.resourceResolver(), path, "@ outputs title=jcr:title desc=jcr:description @ with node=fruits");
+        ExecutionResult result = commands.execute(context.resourceResolver(), path, "outputs title=two['jcr:title'] desc=two['jcr:description']", "with node=fruits");
+        assertNotNull(result);
     }
 
     String testServlet(Map<String,Object> params) throws ServletException, IOException {
diff --git a/src/test/java/org/apache/sling/pipes/internal/CommandUtilTest.java b/src/test/java/org/apache/sling/pipes/internal/CommandUtilTest.java
index fc3f202..7d79464 100644
--- a/src/test/java/org/apache/sling/pipes/internal/CommandUtilTest.java
+++ b/src/test/java/org/apache/sling/pipes/internal/CommandUtilTest.java
@@ -28,6 +28,7 @@
         assertEquals("/path/left/0/un-touc_hed", CommandUtil.embedIfNeeded("/path/left/0/un-touc_hed"));
         assertEquals("/content/json/array/${json.test}", CommandUtil.embedIfNeeded("/content/json/array/${json.test}"));
         assertEquals("${vegetables['jcr:title']}", CommandUtil.embedIfNeeded("vegetables['jcr:title']"));
+        assertEquals("${new Date(\"2018-05-05T11:50:55\")}", CommandUtil.embedIfNeeded("new Date(\"2018-05-05T11:50:55\")"));
         assertEquals("${some + wellformed + script}", CommandUtil.embedIfNeeded("${some + wellformed + script}"));
         assertEquals("${['one','two']}", CommandUtil.embedIfNeeded("['one','two']"));
         assertEquals("${true}", CommandUtil.embedIfNeeded("true"));
diff --git a/src/test/java/org/apache/sling/pipes/internal/PlumberImplTest.java b/src/test/java/org/apache/sling/pipes/internal/PlumberImplTest.java
new file mode 100644
index 0000000..fc4d0af
--- /dev/null
+++ b/src/test/java/org/apache/sling/pipes/internal/PlumberImplTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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.sling.pipes.internal;
+
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.pipes.AbstractPipeTest;
+import org.junit.Test;
+
+import java.lang.reflect.InvocationTargetException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+public class PlumberImplTest extends AbstractPipeTest {
+
+    @Test
+    public void testPurge() throws InvocationTargetException, IllegalAccessException, PersistenceException {
+        execute("mkdir /var/pipes/this/is/going/away | write statusModified=timeutil.of('2018-05-05T11:50:55+01:00')");
+        execute("mkdir /var/pipes/this/is/also/going/away| write statusModified=timeutil.of('2018-05-05T11:50:55+01:00')");
+        String recentDate =  Instant.now().minus(15, ChronoUnit.DAYS).toString();
+        execute("mkdir /var/pipes/this/should/stay| write statusModified=timeutil.of('" + recentDate + "')");
+        assertNotNull("checking that part of the tree has been created", context.resourceResolver().getResource("/var/pipes/this/is"));
+        ((PlumberImpl)plumber).purge(context.resourceResolver(), Instant.now(), 30);
+        assertNull("there should be no more /var/pipes/this/is resource", context.resourceResolver().getResource("/var/pipes/this/is"));
+        assertNotNull("there should still be /var/pipes/this/should/stay resource", context.resourceResolver().getResource("/var/pipes/this/should/stay"));
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/pipes/internal/WritePipeTest.java b/src/test/java/org/apache/sling/pipes/internal/WritePipeTest.java
index 43981ce..a65d632 100644
--- a/src/test/java/org/apache/sling/pipes/internal/WritePipeTest.java
+++ b/src/test/java/org/apache/sling/pipes/internal/WritePipeTest.java
@@ -30,6 +30,11 @@
 import javax.jcr.Node;
 import javax.jcr.NodeIterator;
 import javax.jcr.RepositoryException;
+import java.lang.reflect.InvocationTargetException;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.TemporalUnit;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
 import java.util.Iterator;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -185,4 +190,13 @@
         testIfNode("some random string", true);
         testIfNode(true, true);
     }
+
+    @Test
+    public void testWriteDate() throws InvocationTargetException, IllegalAccessException {
+        execute("echo /content | write date=timeutil.of('2018-05-05T11:50:55+02:00')");
+        ValueMap props = context.resourceResolver().getResource("/content").getValueMap();
+        Calendar cal = props.get("date", Calendar.class);
+        assertNotNull(cal);
+        assertEquals(2018, cal.get(GregorianCalendar.YEAR));
+    }
 }
diff --git a/src/test/java/org/apache/sling/pipes/internal/bindings/TimeUtilTest.java b/src/test/java/org/apache/sling/pipes/internal/bindings/TimeUtilTest.java
new file mode 100644
index 0000000..d72a7d7
--- /dev/null
+++ b/src/test/java/org/apache/sling/pipes/internal/bindings/TimeUtilTest.java
@@ -0,0 +1,45 @@
+/*
+ * 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.sling.pipes.internal.bindings;
+
+import org.apache.sling.pipes.AbstractPipeTest;
+import org.junit.Test;
+
+import java.util.Calendar;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+public class TimeUtilTest extends AbstractPipeTest {
+
+    @Test
+    public void testOfDate() {
+        TimeUtil timeUtil = new TimeUtil();
+        Calendar cal = timeUtil.ofDate("2012-12-02");
+        assertNotNull(cal);
+        assertEquals(2012, cal.get(Calendar.YEAR));
+    }
+
+    @Test
+    public void testOf() {
+        TimeUtil timeUtil = new TimeUtil();
+        Calendar cal = timeUtil.of("2012-12-02T12:30:20+02:00");
+        assertNotNull(cal);
+        assertEquals(2012, cal.get(Calendar.YEAR));
+        assertNotNull(timeUtil.of("2012-12-30T09:20:26Z"));
+    }
+}
\ No newline at end of file