FindOwnersBackend: Fix incompatibility of handling globs

The find-owners backend promises to be compatible with the syntax of the
find-owners plugin, but currently the handling of '*' in globs differs.

The find-owners backend in the code-owners plugin currently handles '*'s
in globs like this:

* '*': matches any string that does not include slashes
* '**': matches any string, including slashes

This matches with how globs work in Java [1], however it is incompatible
with the find-owners plugin that handles '*'s in globs like this:

* '*'/'**': matches any string, including slashes

Change the find-owners backend in the code-owners plugin to be
compatible with the find-owners plugin. For this we simply replace any
single '*' character that appears in globs with '**' before we check if
the glob matches.

[1] https://mincong.io/2019/04/16/glob-expression-understanding/

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: I1ddc8bf326bbcb8867ffd09eebc7a6739ad7d827
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
index ac9ee4d..b0ebb67 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.acceptance.testsuite;
 
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+import com.google.gerrit.plugins.codeowners.backend.FindOwnersGlobMatcher;
 import com.google.gerrit.plugins.codeowners.backend.GlobMatcher;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
 import com.google.gerrit.plugins.codeowners.backend.SimplePathExpressionMatcher;
@@ -47,6 +48,7 @@
   public String matchFileTypeInCurrentFolder(String fileType) {
     PathExpressionMatcher pathExpressionMatcher = getPathExpressionMatcher();
     if (pathExpressionMatcher instanceof GlobMatcher
+        || pathExpressionMatcher instanceof FindOwnersGlobMatcher
         || pathExpressionMatcher instanceof SimplePathExpressionMatcher) {
       return "*." + fileType;
     }
@@ -63,7 +65,8 @@
    */
   public String matchAllFilesInSubfolder(String subfolder) {
     PathExpressionMatcher pathExpressionMatcher = getPathExpressionMatcher();
-    if (pathExpressionMatcher instanceof GlobMatcher) {
+    if (pathExpressionMatcher instanceof GlobMatcher
+        || pathExpressionMatcher instanceof FindOwnersGlobMatcher) {
       return subfolder + "/**";
     } else if (pathExpressionMatcher instanceof SimplePathExpressionMatcher) {
       return subfolder + "/...";
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcher.java b/java/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcher.java
new file mode 100644
index 0000000..2db7b6d
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcher.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2020 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.google.gerrit.plugins.codeowners.backend;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import java.nio.file.Path;
+
+/**
+ * Glob matcher that is compatible with how globs are interpreted by the {@code find-owners} plugin.
+ *
+ * <p>This matcher has the same behaviour as the {@link GlobMatcher} except that:
+ *
+ * <ul>
+ *   <li>'*': matches any string, including slashes (same as '**')
+ * </ul>
+ */
+public class FindOwnersGlobMatcher implements PathExpressionMatcher {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** Singleton instance. */
+  public static FindOwnersGlobMatcher INSTANCE = new FindOwnersGlobMatcher();
+
+  /** Private constructor to prevent creation of further instances. */
+  private FindOwnersGlobMatcher() {}
+
+  @Override
+  public boolean matches(String glob, Path relativePath) {
+    String adaptedGlob = replaceSingleStarWithDoubleStar(glob);
+    logger.atFine().log("adapted glob = %s", adaptedGlob);
+    return GlobMatcher.INSTANCE.matches(adaptedGlob, relativePath);
+  }
+
+  /**
+   * Replaces any single '*' in the given glob with '**'. Non-single '*'s, like '**' or '***', stay
+   * unchanged.
+   *
+   * @param glob glob in which any single '*' should be replaced by '**'
+   */
+  @VisibleForTesting
+  String replaceSingleStarWithDoubleStar(String glob) {
+    StringBuilder adaptedGlob = new StringBuilder();
+    Character previousChar = null;
+    boolean maybeSingleStar = false;
+    for (char nextCharacter : glob.toCharArray()) {
+      if (maybeSingleStar && nextCharacter != '*') {
+        // the previous character was a '*' that was not preceded by '*' (maybeSingleStar == true),
+        // since the next character is not '*', we are now sure that the previous character was a
+        // single '*' which should be replaced by '**',
+        // to do this append another '*'
+        adaptedGlob.append('*');
+      }
+      adaptedGlob.append(nextCharacter);
+
+      // the current character may be a single '*' if it's not preceded by '*'
+      maybeSingleStar =
+          nextCharacter == '*' && (previousChar == null || previousChar.charValue() != '*');
+      previousChar = nextCharacter;
+    }
+
+    if (maybeSingleStar) {
+      // the last character was a '*' that was not preceded by '*'
+      adaptedGlob.append('*');
+    }
+
+    return adaptedGlob.toString();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
index ab08201..15b4385 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
@@ -17,7 +17,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
-import com.google.gerrit.plugins.codeowners.backend.GlobMatcher;
+import com.google.gerrit.plugins.codeowners.backend.FindOwnersGlobMatcher;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -61,7 +61,7 @@
 
   @Override
   public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
-    return Optional.of(GlobMatcher.INSTANCE);
+    return Optional.of(FindOwnersGlobMatcher.INSTANCE);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
index 0a2c144..df2542e 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
@@ -23,7 +23,10 @@
 
 java_library(
     name = "testbases",
-    srcs = glob(["Abstract*.java"]),
+    srcs = glob([
+        "Abstract*.java",
+        "GlobMatcherTest.java",
+    ]),
     deps = [
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/common:annotations",
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcherTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcherTest.java
new file mode 100644
index 0000000..7e0c03e
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcherTest.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2020 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.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+/** Tests for {@link FindOwnersGlobMatcher}. */
+public class FindOwnersGlobMatcherTest extends GlobMatcherTest {
+  @Override
+  protected PathExpressionMatcher getPathExpressionMatcher() {
+    return FindOwnersGlobMatcher.INSTANCE;
+  }
+
+  @Test
+  public void singleStarInGlobIsReplacedWithDoubleStar() throws Exception {
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("*.md"))
+        .isEqualTo("**.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("foo/*.md"))
+        .isEqualTo("foo/**.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("*/foo/*.md"))
+        .isEqualTo("**/foo/**.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("foo/*"))
+        .isEqualTo("foo/**");
+  }
+
+  @Test
+  public void doubleStarInGlobIsNotReplaced() throws Exception {
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("**.md"))
+        .isEqualTo("**.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("foo/**.md"))
+        .isEqualTo("foo/**.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("**/foo/**.md"))
+        .isEqualTo("**/foo/**.md");
+  }
+
+  @Test
+  public void tripleStarInGlobIsNotReplaced() throws Exception {
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("***.md"))
+        .isEqualTo("***.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("foo/***.md"))
+        .isEqualTo("foo/***.md");
+    assertThat(FindOwnersGlobMatcher.INSTANCE.replaceSingleStarWithDoubleStar("***/foo/***.md"))
+        .isEqualTo("***/foo/***.md");
+  }
+
+  /**
+   * This test differs from the base class ({@link GlobMatcherTest}), since for {@link
+   * FindOwnersGlobMatcher} {@code *} also matches slashes and the test in the base class has an
+   * assertion that checks that slashes are not matched by {@code *}.
+   */
+  @Test
+  @Override
+  public void matchFileTypeInCurrentFolder() throws Exception {
+    String pathExpression = "*.md";
+    assertMatch(pathExpression, "README.md", "config.md");
+    assertNoMatch(pathExpression, "README", "README.md5");
+  }
+
+  @Test
+  public void matchFileTypeInCurrentFolderAndAllSubfoldersBySingleStar() throws Exception {
+    String pathExpression = "*.md";
+    assertMatch(pathExpression, "README.md", "config.md", "foo/README.md", "foo/bar/README.md");
+    assertNoMatch(pathExpression, "README", "README.md5");
+  }
+
+  @Test
+  public void matchAllFilesInSubfolderBySingleStar() throws Exception {
+    String pathExpression = "foo/*";
+    assertMatch(
+        pathExpression,
+        "foo/README.md",
+        "foo/config.txt",
+        "foo/bar/README.md",
+        "foo/bar/baz/README.md");
+    assertNoMatch(pathExpression, "README", "foo2/README");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java
index 14bb3c3..ff6f184 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackendTest.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.AbstractFileBasedCodeOwnerBackendTest;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigParser;
-import com.google.gerrit.plugins.codeowners.backend.GlobMatcher;
+import com.google.gerrit.plugins.codeowners.backend.FindOwnersGlobMatcher;
 import org.junit.Test;
 
 /** Tests for {@link FindOwnersBackend}. */
@@ -41,6 +41,8 @@
 
   @Test
   public void getPathExpressionMatcher() throws Exception {
-    assertThat(codeOwnerBackend.getPathExpressionMatcher()).value().isInstanceOf(GlobMatcher.class);
+    assertThat(codeOwnerBackend.getPathExpressionMatcher())
+        .value()
+        .isInstanceOf(FindOwnersGlobMatcher.class);
   }
 }
diff --git a/resources/Documentation/backend-find-owners-cookbook.md b/resources/Documentation/backend-find-owners-cookbook.md
index 7b49820..2af4e74 100644
--- a/resources/Documentation/backend-find-owners-cookbook.md
+++ b/resources/Documentation/backend-find-owners-cookbook.md
@@ -142,19 +142,18 @@
   per-file *.md=tina.toe@example.com
 ```
 \
-To match '*.md' in the current directory and all its subdirectories use:
+This matches all '*.md' in the current directory and all its subdirectories.
 
-```
-  per-file **.md=tina.toe@example.com
-```
-\
+**NOTE**: Using '*.md' is the same as using '**.md' (both expressions match
+files in the current directory and in all subdirectories).
+
 **NOTE:** The syntax for path expressions / globs is explained
 [here](path-expressions.html#globs).
 
 ### <a id="defineCodeOwnersForAllFileInASubdirectory">Define code owners for all files in a subdirectory
 
 It is discouraged to use path expressions that explicitly name subdirectories
-such as `my-subdir/**` as they will break when the subdirectory gets
+such as `my-subdir/*` as they will break when the subdirectory gets
 renamed/moved. Instead prefer to define these code owners in `my-subdir/OWNERS`
 so that the code owners for the subdirectory stay intact when the subdirectory
 gets renamed/moved.
@@ -165,7 +164,7 @@
 expression matches all files in the subdirectory:
 
 ```
-  per-file my-subdir/**=tina.toe@example.com
+  per-file my-subdir/*=tina.toe@example.com
 ```
 
 ### <a id="defineAGroupAsCodeOwner">Define a group as code owner
@@ -322,7 +321,7 @@
 `OWNERS` files that exempts all '*.md' files (in the current directory and all
 subdirectories) from requiring code owner approvals:
 ```
-  per-file **.md=*
+  per-file *.md=*
 ```
 \
 **NOTE:** Files that are not owned by anyone are **not** excluded from requiring
diff --git a/resources/Documentation/backend-find-owners.md b/resources/Documentation/backend-find-owners.md
index b0c0af6..2510e3a 100644
--- a/resources/Documentation/backend-find-owners.md
+++ b/resources/Documentation/backend-find-owners.md
@@ -227,7 +227,8 @@
 In the example below, Jana Roe, John Doe and the code owners that are inherited
 from parent `OWNERS` files are code owners of all files that are contained in
 the directory that contains the `OWNERS` file. In addition Richard Roe is a code
-owner of the `docs.config` file and all `*.md` files in this directory.
+owner of the `docs.config` file in this directory and all `*.md` files in this
+directory and the subdirectories.
 
 ```
   jane.roe@example.com
@@ -237,7 +238,7 @@
 \
 ##### <a id="doNotUsePathExpressionsForSubdirectories">
 **NOTE:** It is discouraged to use path expressions that explicitly name
-subdirectories such as `my-subdir/**` as they will break when the subdirectory
+subdirectories such as `my-subdir/*` as they will break when the subdirectory
 gets renamed/moved. Instead prefer to define these code owners in
 `my-subdir/OWNERS` so that the code owners for the subdirectory stay intact when
 the subdirectory gets renamed/moved.
@@ -268,9 +269,10 @@
 from parent directories.
 
 In the example below, Richard Roe is the only code owner of the `docs.config`
-file and all `*.md` files in this directory. All other files in this directory
-and its subdirectories are owned by Jana Roe, John Doe and the code owners that
-are inherited from parent directories.
+file in this directory and all `*.md` files in this directory and the
+subdirectories. All other files in this directory and its subdirectories are
+owned by Jana Roe, John Doe and the code owners that are inherited from parent
+directories.
 
 ```
   jane.roe@example.com
diff --git a/resources/Documentation/path-expressions.md b/resources/Documentation/path-expressions.md
index 7137d4c..bedbbfe 100644
--- a/resources/Documentation/path-expressions.md
+++ b/resources/Documentation/path-expressions.md
@@ -15,8 +15,7 @@
 
 Globs support the following wildcards:
 
-* `*`: matches any string that does not include slashes
-* `**`: matches any string, including slashes
+* `*`/`**`: matches any string, including slashes
 * `?`: matches any character
 * `[abc]`: matches one character given in the bracket
 * `[a-c]`: matches one character from the range given in the bracket