Introduce grammar to parse task references

Uses antlr4 to parse the task reference syntax. Also,
adds a missing test case to ensure functionality.

Include 'maven' in the zuul config to ensure it's installed on the host
which fixes the error:

 antlr4_runtime requires mvn as a dependency. Please check your PATH.

Change-Id: I439d813f63639558109f547f0e769d6833d4c680
diff --git a/.zuul.yaml b/.zuul.yaml
index 52c12ce..6e83fbf 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -2,6 +2,7 @@
     name: plugins-task-build
     parent: gerrit-plugin-build
     pre-run:
+        - tools/playbooks/install_maven.yaml
         - tools/playbooks/install_docker.yaml
         - tools/playbooks/install_python3-distutils.yaml
     vars:
diff --git a/BUILD b/BUILD
index ec0e964..8782740 100644
--- a/BUILD
+++ b/BUILD
@@ -8,6 +8,7 @@
 load("//tools/js:eslint.bzl", "eslint")
 load("//tools/bzl:junit.bzl", "junit_tests")
 load("@rules_java//java:defs.bzl", "java_library", "java_plugin")
+load("@rules_antlr//antlr:antlr4.bzl", "antlr")
 
 plugin_name = "task"
 
@@ -29,6 +30,19 @@
     exports = ["@auto-value//jar"],
 )
 
+antlr(
+    name = "task_reference",
+    srcs = ["src/main/antlr4/com/googlesource/gerrit/plugins/task/TaskReference.g4"],
+    package = "com.googlesource.gerrit.plugins.task",
+    visibility = ["//visibility:public"],
+)
+
+java_library(
+    name = "task_reference_parser",
+    srcs = [":task_reference"],
+    deps = ["@antlr4_runtime//jar"],
+)
+
 gerrit_plugin(
     name = plugin_name,
     srcs = glob(["src/main/java/**/*.java"]),
@@ -40,7 +54,11 @@
     ],
     resource_jars = [":gr-task-plugin"],
     resources = glob(["src/main/resources/**/*"]),
-    deps = [":auto-value"],
+    deps = [
+        ":auto-value",
+        ":task_reference_parser",
+        "@antlr4_runtime//jar",
+    ],
     javacopts = [ "-Werror", "-Xlint:all", "-Xlint:-classfile", "-Xlint:-processing"],
 )
 
diff --git a/external_plugin_deps.bzl b/external_plugin_deps.bzl
index fdf9c1c..901cd0f 100644
--- a/external_plugin_deps.bzl
+++ b/external_plugin_deps.bzl
@@ -1,4 +1,5 @@
 load("@bazel_tools//tools/build_defs/repo:maven_rules.bzl", "maven_jar")
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
 
 def external_plugin_deps():
     AUTO_VALUE_VERSION = "1.7.4"
@@ -14,3 +15,42 @@
         artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
         sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
     )
+
+    http_archive(
+        name = "rules_antlr",
+        sha256 = "26e6a83c665cf6c1093b628b3a749071322f0f70305d12ede30909695ed85591",
+        strip_prefix = "rules_antlr-0.5.0",
+        urls = ["https://github.com/marcohu/rules_antlr/archive/0.5.0.tar.gz"],
+    )
+
+    maven_jar(
+        name = "antlr3_runtime",
+        artifact = "org.antlr:antlr-runtime:3.5.2",
+        sha1 = "cd9cd41361c155f3af0f653009dcecb08d8b4afd",
+    )
+
+    ANTLR_VERSION = "4.9.3"
+
+    maven_jar(
+        name = "antlr4_runtime",
+        artifact = "org.antlr:antlr4-runtime:" + ANTLR_VERSION,
+        sha1 = "81befc16ebedb8b8aea3e4c0835dd5ca7e8523a8",
+    )
+
+    maven_jar(
+        name = "antlr4_tool",
+        artifact = "org.antlr:antlr4:" + ANTLR_VERSION,
+        sha1 = "9d47afaa75d70903b5b77413b034d6b201d7d5d6",
+    )
+
+    maven_jar(
+        name = "stringtemplate4",
+        artifact = "org.antlr:ST4:4.3.1",
+        sha1 = "9c61ac6d17b7f450b4048742c2cc73787972518e",
+    )
+
+    maven_jar(
+        name = "javax_json",
+        artifact = "org.glassfish:javax.json:1.0.4",
+        sha1 = "3178f73569fd7a1e5ffc464e680f7a8cc784b85a",
+    )
diff --git a/src/main/antlr4/com/googlesource/gerrit/plugins/task/TaskReference.g4 b/src/main/antlr4/com/googlesource/gerrit/plugins/task/TaskReference.g4
new file mode 100644
index 0000000..5631e07
--- /dev/null
+++ b/src/main/antlr4/com/googlesource/gerrit/plugins/task/TaskReference.g4
@@ -0,0 +1,89 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed 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.
+
+/**
+ *
+ * This file defines the grammar used for Task Reference
+ *
+ * TASK_REF = [ [ TASK_FILE_PATH ] '^' ] TASK_NAME
+ *
+ * Examples:
+ *
+ * file: All-Projects:refs/meta/config:task.config
+ * reference: foo.config^sample
+ * Implied task:
+ *     file: All-Projects:refs/meta/config:task/foo.config task: sample
+ *
+ * file: All-Projects:refs/meta/config:task/dir/bar.config
+ * reference: /foo.config^sample
+ * Implied task:
+ *     file: All-Projects:refs/meta/config:task/foo.config task: sample
+ *
+ * file: All-Projects:refs/meta/config:task/dir/bar.config
+ * reference: sub-dir/foo.config^sample
+ * Implied task:
+ *     file: All-Projects:refs/meta/config:task/dir/sub-dir/foo.config task: sample
+ *
+ * file: All-Projects:refs/meta/config:task/dir/bar.config
+ * reference: ^sample
+ * Implied task:
+ *     file: All-Projects:refs/meta/config:task.config task: sample
+ *
+ */
+
+grammar TaskReference;
+
+options {
+  language = Java;
+}
+
+reference
+  : file_path? TASK
+  ;
+
+file_path
+ : (absolute| relative)? TASK_DELIMETER
+ ;
+
+absolute
+ : '/' relative
+ ;
+
+relative
+ : dir* NAME
+ ;
+
+dir
+ : (NAME '/')
+ ;
+
+TASK
+ : (~'^')+ EOF
+ ;
+
+NAME
+ : URL_ALLOWED_CHARS_EXCEPT_FWD_SLASH+
+ ;
+
+fragment URL_ALLOWED_CHARS_EXCEPT_FWD_SLASH
+ : ':' | '?' | '#' | '[' | ']' | '@'
+ |'!' | '$' | '&' | '\'' | '(' | ')'
+ | '*' | '+' | ',' | ';' | '=' | '%'
+ | 'A'..'Z' | 'a'..'z' | '0'..'9'
+ | '_' | '.' | '\\' | '-' | '~'
+ ;
+
+TASK_DELIMETER
+ : '^'
+ ;
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java b/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java
index 2791d38..3361464 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java
@@ -129,6 +129,8 @@
           return task;
         }
       }
+    } catch (RuntimeConfigInvalidException e) {
+      throw e.checkedException;
     } catch (NoSuchElementException e) {
       // expression was not optional but we ran out of names to try
       throw new ConfigInvalidException("task not defined");
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/RuntimeConfigInvalidException.java b/src/main/java/com/googlesource/gerrit/plugins/task/RuntimeConfigInvalidException.java
new file mode 100644
index 0000000..cc0ed94
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/RuntimeConfigInvalidException.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed 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 com.googlesource.gerrit.plugins.task;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class RuntimeConfigInvalidException extends RuntimeException {
+  protected static final long serialVersionUID = 1L;
+  protected ConfigInvalidException checkedException;
+
+  public RuntimeConfigInvalidException(ConfigInvalidException e) {
+    super(e);
+    this.checkedException = e;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java
index e47e880..759caba 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java
@@ -18,18 +18,21 @@
 import java.util.NoSuchElementException;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /**
  * A TaskExpression represents a config string pointing to an expression which includes zero or more
- * task names separated by a '|', and potentially termintated by a '|'. If the expression is not
- * terminated by a '|' it indicates that task resolution of at least one task is required. Task
+ * task references separated by a '|', and potentially termintated by a '|'. If the expression is
+ * not terminated by a '|' it indicates that task resolution of at least one task is required. Task
  * selection priority is from left to right. This can be expressed as:
  *
  * <pre>
- * TASK_REF = [ [ TASK_FILE_PATH ] '^' ] TASK_NAME
- * TASK_EXPR = TASK_REF [ WHITE_SPACE * '|' [ WHITE_SPACE * TASK_EXPR ] ]
+ * TASK_EXPR = TASK_REFERENCE [ WHITE_SPACE * '|' [ WHITE_SPACE * TASK_EXPR ] ]
  * </pre>
  *
+ * <a href="file:../../../../../../antlr4/com/googlesource/gerrit/plugins/task/TaskReference.g4">See
+ * this for Task Reference</a>
+ *
  * <p>Example expressions to prioritized names and requirements:
  *
  * <ul>
@@ -41,12 +44,6 @@
  *       <pre> "shadenfreud |"     -> ("shadenfreud")                  optional</pre>
  *   <li>
  *       <pre> "foo | bar |"       -> ("foo", "bar")                   optional</pre>
- *   <li>
- *       <pre> "/foo^bar | baz |"  -> ("task/foo^bar", "baz")          optional</pre>
- *   <li>
- *       <pre> "foo^bar | baz |"   -> ("cur_dir/foo^bar", "baz")       optional</pre>
- *   <li>
- *       <pre> "^bar | baz |"      -> ("task.config^bar", "baz")       optional</pre>
  * </ul>
  */
 public class TaskExpression implements Iterable<TaskKey> {
@@ -86,7 +83,11 @@
           throw new NoSuchElementException("No more names, yet expression was not optional");
         }
         hasNext = null;
-        return new TaskReference(key.file(), m.group(1)).getTaskKey();
+        try {
+          return new TaskReference(key.file(), m.group(1)).getTaskKey();
+        } catch (ConfigInvalidException e) {
+          throw new RuntimeConfigInvalidException(e);
+        }
       }
     };
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskKey.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskKey.java
index f21e732..6acab3e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskKey.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskKey.java
@@ -15,7 +15,11 @@
 package com.googlesource.gerrit.plugins.task;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
 import com.google.gerrit.entities.BranchNameKey;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** An immutable reference to a task in task config file. */
 @AutoValue
@@ -49,4 +53,67 @@
   public boolean isTasksFactoryGenerated() {
     return subSection().section().equals(CONFIG_TASKS_FACTORY);
   }
+
+  public static class Builder {
+    protected final FileKey relativeTo;
+    protected String file;
+    protected String task;
+
+    Builder(FileKey relativeTo) {
+      this.relativeTo = relativeTo;
+    }
+
+    public TaskKey buildTaskKey() {
+      return TaskKey.create(
+          isRelativePath() ? relativeTo : FileKey.create(relativeTo.branch(), file), task);
+    }
+
+    public void setAbsolute() {
+      file = TaskFileConstants.TASK_DIR;
+    }
+
+    public void setPath(Path path) throws ConfigInvalidException {
+      Path parentDir = Paths.get(relativeTo.file()).getParent();
+      if (parentDir == null) {
+        parentDir = Paths.get(TaskFileConstants.TASK_DIR);
+      }
+
+      file =
+          isRelativePath()
+              ? parentDir.resolve(path).toString()
+              : Paths.get(file).resolve(path).toString();
+      throwIfInvalidPath();
+    }
+
+    public void setRefRootFile() throws ConfigInvalidException {
+      Preconditions.checkState(!isFileAlreadySet());
+      file = TaskFileConstants.TASK_CFG;
+    }
+
+    public void setTaskName(String task) {
+      this.task = task;
+    }
+
+    protected void throwIfInvalidPath() throws ConfigInvalidException {
+      Path path = Paths.get(file);
+      if (!path.startsWith(TaskFileConstants.TASK_DIR)
+          && !path.equals(Paths.get(TaskFileConstants.TASK_CFG))) {
+        throw new ConfigInvalidException(
+            "Invalid config location, path should be "
+                + TaskFileConstants.TASK_CFG
+                + " or under "
+                + TaskFileConstants.TASK_DIR
+                + " directory");
+      }
+    }
+
+    /** Returns true when the path implies relative or same file. */
+    protected boolean isRelativePath() {
+      return file == null;
+    }
+
+    protected boolean isFileAlreadySet() {
+      return file != null;
+    }
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskReference.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskReference.java
index 2463a0c..139b27d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskReference.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskReference.java
@@ -14,9 +14,17 @@
 
 package com.googlesource.gerrit.plugins.task;
 
-import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.NoSuchElementException;
+import org.antlr.v4.runtime.BaseErrorListener;
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.Lexer;
+import org.antlr.v4.runtime.RecognitionException;
+import org.antlr.v4.runtime.Recognizer;
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.antlr.v4.runtime.tree.ParseTreeWalker;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** This class is used by TaskExpression to decode the task from task reference. */
 public class TaskReference {
@@ -31,37 +39,78 @@
     }
   }
 
-  public TaskKey getTaskKey() {
-    String[] referenceSplit = reference.split("\\^");
-    switch (referenceSplit.length) {
-      case 1:
-        return TaskKey.create(currentFile, referenceSplit[0]);
-      case 2:
-        return TaskKey.create(getFileKey(referenceSplit[0]), referenceSplit[1]);
-      default:
-        throw new NoSuchElementException();
+  public TaskKey getTaskKey() throws ConfigInvalidException {
+    TaskKey.Builder builder = new TaskKey.Builder(currentFile);
+    ParseTreeWalker walker = new ParseTreeWalker();
+    try {
+      walker.walk(new TaskReferenceListener(builder), parse());
+    } catch (RuntimeConfigInvalidException e) {
+      throw e.checkedException;
+    }
+    return builder.buildTaskKey();
+  }
+
+  protected ParseTree parse() {
+    Lexer lexer = new TaskReferenceLexer(CharStreams.fromString(reference));
+    lexer.removeErrorListeners();
+    lexer.addErrorListener(TaskReferenceErrorListener.INSTANCE);
+    return new TaskReferenceParser(new CommonTokenStream(lexer)).reference();
+  }
+
+  protected static class TaskReferenceErrorListener extends BaseErrorListener {
+    protected static final TaskReferenceErrorListener INSTANCE = new TaskReferenceErrorListener();
+
+    @Override
+    public void syntaxError(
+        Recognizer<?, ?> recognizer,
+        Object offendingSymbol,
+        int line,
+        int charPositionInLine,
+        String msg,
+        RecognitionException e) {
+      throw new NoSuchElementException();
     }
   }
 
-  protected FileKey getFileKey(String referenceFile) {
-    return FileKey.create(currentFile.branch(), getFile(referenceFile));
-  }
+  protected class TaskReferenceListener extends TaskReferenceBaseListener {
+    TaskKey.Builder builder;
 
-  protected String getFile(String referencedFile) {
-    if (referencedFile.isEmpty()) { // Implies a task from root task.config
-      return TaskFileConstants.TASK_CFG;
+    TaskReferenceListener(TaskKey.Builder builder) {
+      this.builder = builder;
     }
 
-    if (referencedFile.startsWith("/")) { // Implies absolute path to the config is provided
-      return Paths.get(TaskFileConstants.TASK_DIR, referencedFile).toString();
+    @Override
+    public void enterAbsolute(TaskReferenceParser.AbsoluteContext ctx) {
+      builder.setAbsolute();
     }
 
-    // Implies a relative path to sub-directory
-    Path dir = Paths.get(currentFile.file()).getParent();
-    if (dir == null) { // Relative path in root task.config should refer to files under task dir
-      return Paths.get(TaskFileConstants.TASK_DIR, referencedFile).toString();
-    } else {
-      return Paths.get(dir.toString(), referencedFile).toString();
+    @Override
+    public void enterRelative(TaskReferenceParser.RelativeContext ctx) {
+      try {
+        builder.setPath(
+            ctx.dir().stream()
+                .map(dir -> Paths.get(dir.NAME().getText()))
+                .reduce(Paths.get(""), (a, b) -> a.resolve(b))
+                .resolve(ctx.NAME().getText()));
+      } catch (ConfigInvalidException e) {
+        throw new RuntimeConfigInvalidException(e);
+      }
+    }
+
+    @Override
+    public void enterReference(TaskReferenceParser.ReferenceContext ctx) {
+      builder.setTaskName(ctx.TASK().getText());
+    }
+
+    @Override
+    public void enterFile_path(TaskReferenceParser.File_pathContext ctx) {
+      if (ctx.absolute() == null && ctx.relative() == null) {
+        try {
+          builder.setRefRootFile();
+        } catch (ConfigInvalidException e) {
+          throw new RuntimeConfigInvalidException(e);
+        }
+      }
     }
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/task/TaskReferenceTest.java b/src/test/java/com/googlesource/gerrit/plugins/task/TaskReferenceTest.java
index 8d079ff..5f309d0 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/task/TaskReferenceTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/task/TaskReferenceTest.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.entities.Project;
 import java.util.NoSuchElementException;
 import junit.framework.TestCase;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.junit.Test;
 
 public class TaskReferenceTest extends TestCase {
@@ -53,13 +54,19 @@
   }
 
   @Test
-  public void testReferencingRelativeTask() {
+  public void testReferencingRelativeDirTask() {
     String reference = " dir/common.config^" + SIMPLE;
     assertEquals(
         createTaskKey(SUB_COMMON_CFG, SIMPLE), getTaskFromReference(COMMON_CFG, reference));
   }
 
   @Test
+  public void testReferencingRelativeFileTask() {
+    String reference = "common.config^" + SIMPLE;
+    assertEquals(createTaskKey(COMMON_CFG, SIMPLE), getTaskFromReference(COMMON_CFG, reference));
+  }
+
+  @Test
   public void testReferencingAbsoluteTask() {
     String reference = " /common.config^" + SIMPLE;
     assertEquals(
@@ -79,7 +86,11 @@
   }
 
   protected static TaskKey getTaskFromReference(FileKey file, String expression) {
-    return new TaskReference(file, expression).getTaskKey();
+    try {
+      return new TaskReference(file, expression).getTaskKey();
+    } catch (ConfigInvalidException e) {
+      throw new NoSuchElementException();
+    }
   }
 
   protected static TaskKey createTaskKey(FileKey file, String task) {
diff --git a/tools/playbooks/install_maven.yaml b/tools/playbooks/install_maven.yaml
new file mode 100644
index 0000000..ae1690d
--- /dev/null
+++ b/tools/playbooks/install_maven.yaml
@@ -0,0 +1,8 @@
+- hosts: all
+  tasks:
+    - name: Install maven
+      become: true
+      package:
+        name:
+          - maven
+        state: present