Use custom Matcher to find and replace properties

The simplest way to find and replace properties in Strings is using a
Pattern Matcher. However, implementing a specialized Matcher using
simpler String primitives is faster, so do so using an API that looks
and feels as simple as the Pattern Matcher API.

In a sample walking ancestors use case, this caching and saves a small
but measurable amount of the total time. In the case of a task.config
which walks all dependencies for a change when run with status:open
--no-limit --task--applicable the gain can be seen below.

Before this change: 8m7s 3m16s 3m26s 3m16s 3m21s
After this change: 2m34s 2m28s 2m40s 2m34s 2m39s

Change-Id: Ief2d6a24f6952828214f55a4a4cf9ea0f3768f42
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/properties/AbstractExpander.java b/src/main/java/com/googlesource/gerrit/plugins/task/properties/AbstractExpander.java
index bccf03c..3813319 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/properties/AbstractExpander.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/properties/AbstractExpander.java
@@ -20,8 +20,6 @@
 import java.util.List;
 import java.util.Set;
 import java.util.function.Function;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 /**
  * Use to expand properties like ${property} in Strings into their values.
@@ -41,9 +39,6 @@
  * the name/value associations via the getValueForName() method.
  */
 public abstract class AbstractExpander {
-  // "${_name}" -> group(1) = "_name"
-  protected static final Pattern PATTERN = Pattern.compile("\\$\\{([^}]+)\\}");
-
   /**
    * Returns expanded object if property found in the Strings in the object's Fields (except the
    * excluded ones). Returns same object if no expansions occurred.
@@ -138,13 +133,13 @@
     if (text == null) {
       return null;
     }
-    Matcher m = PATTERN.matcher(text);
+    Matcher m = new Matcher(text);
     if (!m.find()) {
       return text;
     }
     StringBuffer out = new StringBuffer();
     do {
-      m.appendReplacement(out, Matcher.quoteReplacement(getValueForName(m.group(1))));
+      m.appendValue(out, getValueForName(m.getName()));
     } while (m.find());
     m.appendTail(out);
     return out.toString();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/properties/Matcher.java b/src/main/java/com/googlesource/gerrit/plugins/task/properties/Matcher.java
new file mode 100644
index 0000000..abe203d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/properties/Matcher.java
@@ -0,0 +1,57 @@
+// 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.properties;
+
+/** A handcrafted properties Matcher which has an API similar to an RE Matcher, but is faster. */
+public class Matcher {
+  String text;
+  int start;
+  int nameStart;
+  int end;
+  int cursor;
+
+  public Matcher(String text) {
+    this.text = text;
+  }
+
+  public boolean find() {
+    start = text.indexOf("${", cursor);
+    nameStart = start + 2;
+    if (start < 0 || text.length() < nameStart + 1) {
+      return false;
+    }
+    end = text.indexOf('}', nameStart);
+    return end >= 0;
+  }
+
+  public String getName() {
+    return text.substring(nameStart, end);
+  }
+
+  public void appendValue(StringBuffer buffer, String value) {
+    if (start > cursor) {
+      buffer.append(text.substring(cursor, start));
+    }
+    buffer.append(value);
+    cursor = end + 1;
+  }
+
+  public void appendTail(StringBuffer buffer) {
+    if (cursor < text.length()) {
+      buffer.append(text.substring(cursor));
+      cursor = text.length();
+    }
+  }
+}