Add per-file set noparent and more tests

* Implement new "per-file <globs> = set noparent" statement.
  * Update syntax.md with better explanations and examples.
  * Remove duplicated syntax comment in Parser.java.
  * Semantics of "per-file <globs> = <owner-email-list>" is changed.
    Files matching the <globs> now inherit global non-per-file owners
    unless "per-file <globs> = set noparent" is used.
  * For example, to assign only jj@g.com as the owner of *.java files:
      per-file *.java = jj@g.com
      per-file *.java = set noparent
* Fix error in Parser.Result.append; add test case.
* Add more tests in ParserTest, FindOwnersIT, OwnersValidatorTest.

Change-Id: I43a306bc9af85badc40b44507d4b2d021888ecd4
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java
index 0729f72..8048558 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java
@@ -64,6 +64,7 @@
   Map<String, Set<String>> path2Owners = new HashMap<>(); // dir or file glob to owner emails
   Set<String> readDirs = new HashSet<>(); // directories in which we have checked OWNERS
   Set<String> stopLooking = new HashSet<>(); // directories where OWNERS has "set noparent"
+  Set<String> noParentGlobs = new HashSet<>(); // per-file globs with "set noparent"
   Map<String, String> preferredEmails = new HashMap<>(); // owner email to preferred email
   List<String> errors = new ArrayList<>(); // error messages
   List<String> logs = new ArrayList<>(); // trace/debug messages
@@ -172,10 +173,14 @@
     Util.addToMap(owner2Paths, owner, path);
     Util.addToMap(path2Owners, path, owner);
     if (path.length() > 0 && path.charAt(path.length() - 1) != '/') {
-      Util.addToMap(dir2Globs, Util.getDirName(path) + "/", path); // A file glob.
+      add2dir2Globs(Util.getDirName(path) + "/", path); // A file glob.
     }
   }
 
+  void add2dir2Globs(String dir, String glob) {
+    Util.addToMap(dir2Globs, dir, glob);
+  }
+
   void addPreferredEmails(Set<String> ownerEmails) {
     List<String> owners = new ArrayList<>(ownerEmails);
     owners.removeIf(o -> preferredEmails.get(o) != null);
@@ -222,6 +227,7 @@
     if (result.stopLooking) {
       stopLooking.add(dirPath);
     }
+    noParentGlobs.addAll(result.noParentGlobs);
     addPreferredEmails(result.owner2paths.keySet());
     for (String owner : result.owner2paths.keySet()) {
       String email = preferredEmails.get(owner);
@@ -229,6 +235,9 @@
         addOwnerPathPair(email, path);
       }
     }
+    for (String glob : result.noParentGlobs) {
+      add2dir2Globs(Util.getDirName(glob) + "/", glob);
+    }
     if (config.getReportSyntaxError()) {
       result.warnings.forEach(w -> logger.atWarning().log(w));
       result.errors.forEach(w -> logger.atSevere().log(w));
@@ -286,7 +295,7 @@
     for (String fileName : files) {
       fileName = Util.addDotPrefix(fileName);
       logs.add("checkFile:" + fileName);
-      String dirPath = Util.getParentDir(fileName);
+      String dirPath = Util.getParentDir(fileName); // ".", "./d1", "./d1/d2", etc.
       String baseName = fileName.substring(dirPath.length() + 1);
       int distance = 1;
       FileSystem fileSystem = FileSystems.getDefault();
@@ -298,26 +307,26 @@
       while (true) {
         int savedSizeOfPaths = paths.size();
         logs.add("checkDir:" + dirPath);
+        boolean foundNoParentGlob = false;
         if (dir2Globs.containsKey(dirPath + "/")) {
           Set<String> patterns = dir2Globs.get(dirPath + "/");
           for (String pat : patterns) {
             PathMatcher matcher = fileSystem.getPathMatcher("glob:" + pat);
             if (matcher.matches(Paths.get(dirPath + "/" + baseName))) {
               foundStar |= findStarOwner(pat, distance, paths, distances);
+              foundNoParentGlob |= noParentGlobs.contains(pat);
               // Do not break here, a file could match multiple globs
               // with different owners.
               // OwnerWeights.add won't add duplicated files.
             }
           }
-          // NOTE: A per-file directive can only specify owner emails,
-          // not "set noparent".
         }
-        // If baseName does not match per-file glob, paths is not changed.
-        // Then we should check the general non-per-file owners.
-        if (paths.size() == savedSizeOfPaths) {
+        // Unless foundNoParentGlob, we should check the general non-per-file owners.
+        if (!foundNoParentGlob) {
           foundStar |= findStarOwner(dirPath + "/", distance, paths, distances);
         }
         if (stopLooking.contains(dirPath + "/") // stop looking parent
+            || foundNoParentGlob                // per-file "set noparent"
             || !dirPath.contains("/") /* root */) {
           break;
         }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java
index deda93e..73947ad 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java
@@ -253,9 +253,11 @@
       // no email address to check
     } else if ((email = Parser.parseEmail(line)) != null) {
       collectEmail(email2lines, email, path, lineNumber);
-    } else if ((emails = Parser.parsePerFileEmails(line)) != null) {
+    } else if ((emails = Parser.parsePerFileOwners(line)) != null) {
       for (String e : emails) {
-        collectEmail(email2lines, e, path, lineNumber);
+        if (!e.equals(Parser.TOK_SET_NOPARENT)) {
+          collectEmail(email2lines, e, path, lineNumber);
+        }
       }
     } else if (Parser.isInclude(line)) {
       // Included "OWNERS" files will be checked by themselves.
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Parser.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Parser.java
index 317bea7..ad2fe74 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/Parser.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Parser.java
@@ -31,35 +31,14 @@
 /**
  * Parse lines in an OWNERS file and put them into an OwnersDb.
  *
- * <p>OWNERS file syntax:
- *
- * <pre>
- * lines      := (\s* line? \s* "\n")*
- * line       := "set" \s+ "noparent"
- *            | "per-file" \s+ globs \s* "=" \s* directives
- *            | "file:" \s* glob
- *            | "include" SPACE+ (project SPACE* ":" SPACE*)? filePath
- *            | comment
- *            | directive
- * project    := a Gerrit absolute project path name without space or column character
- * filePath   := a file path name without space or column character
- * directives := directive (comma directive)*
- * directive  := email_address
- *            |  "*"
- * globs      := glob (comma glob)*
- * glob       := [a-zA-Z0-9_-*?.]+
- * comma      := \s* "," \s*
- * comment    := "#" [^"\n"]*
- * </pre>
- *
- * <p>The "file:" directive is not implemented yet.
- *
- * <p>"per-file globs = directives" applies each directive to files matching any of the globs.
- * A glob does not contain directory path.
+ * The syntax, semantics, and some examples are included in syntax.md.
  */
 class Parser {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  // Artifical owner token for "set noparent" when used in per-file.
+  protected static final String TOK_SET_NOPARENT = "set noparent";
+
   // Globs and emails are separated by commas with optional spaces around a comma.
   protected static final String COMMA = "[\\s]*,[\\s]*"; // used in unit tests
 
@@ -72,6 +51,8 @@
 
   // TODO: have a more precise email address pattern.
   private static final String  EMAIL_OR_STAR = "([^\\s<>@,]+@[^\\s<>@#,]+|\\*)";
+  private static final String  EMAIL_LIST =
+      "(" + EMAIL_OR_STAR + "(" + COMMA + EMAIL_OR_STAR + ")*)";
 
   // A Gerrit project name followed by a column and optional spaces.
   private static final String  PROJECT_NAME = "([^\\s:]+" + COLUMN + ")?";
@@ -81,21 +62,24 @@
 
   private static final String  PROJECT_AND_FILE = PROJECT_NAME + FILE_PATH;
 
+  private static final String  SET_NOPARENT = "set[\\s]+noparent";
+
   // Simple input lines with 0 or 1 sub-pattern.
   private static final Pattern PAT_COMMENT = Pattern.compile(BOL + EOL);
   private static final Pattern PAT_EMAIL = Pattern.compile(BOL + EMAIL_OR_STAR + EOL);
   private static final Pattern PAT_FILE = Pattern.compile(BOL + "file:.*" + EOL);
   private static final Pattern PAT_INCLUDE =
       Pattern.compile(BOL + "include[\\s]+" + PROJECT_AND_FILE + EOL);
-  private static final Pattern PAT_NO_PARENT = Pattern.compile(BOL + "set[\\s]+noparent" + EOL);
+  private static final Pattern PAT_NO_PARENT = Pattern.compile(BOL + SET_NOPARENT + EOL);
 
-  // Patterns to match trimmed globs and emails in per-file lines.
-  private static final Pattern PAT_EMAIL_LIST =
-      Pattern.compile("^(" + EMAIL_OR_STAR + "(" + COMMA + EMAIL_OR_STAR + ")*)$");
+  // Patterns to match trimmed globs, emails, and set noparent in per-file lines.
+  private static final Pattern PAT_PER_FILE_OWNERS =
+      Pattern.compile("^(" + EMAIL_LIST + "|(" + SET_NOPARENT + "))$");
   private static final Pattern PAT_GLOBS =
       Pattern.compile("^(" + GLOB + "(" + COMMA + GLOB + ")*)$");
   // PAT_PER_FILE matches a line to two groups: (1) globs, (2) emails
-  // Trimmed 1st group should match PAT_GLOBS; trimmed 2nd group should match PAT_EMAIL_LIST.
+  // Trimmed 1st group should match PAT_GLOBS;
+  // trimmed 2nd group should match PAT_PER_FILE_OWNERS.
   private static final Pattern PAT_PER_FILE =
       Pattern.compile(BOL + "per-file[\\s]+([^=#]+)=[\\s]*([^#]+)" + EOL);
 
@@ -165,18 +149,22 @@
     return parts;
   }
 
+  static String removeExtraSpaces(String s) {
+    return s.trim().replaceAll("[\\s]+", " ");
+  }
+
   static String[] parsePerFile(String line) {
     Matcher m = PAT_PER_FILE.matcher(line);
     if (!m.matches() || !isGlobs(m.group(1).trim())
-        || !PAT_EMAIL_LIST.matcher(m.group(2).trim()).matches()) {
+        || !PAT_PER_FILE_OWNERS.matcher(m.group(2).trim()).matches()) {
       return null;
     }
-    return new String[]{m.group(1).trim(), m.group(2).trim()};
+    return new String[]{removeExtraSpaces(m.group(1)), removeExtraSpaces(m.group(2))};
   }
 
-  static String[] parsePerFileEmails(String line) {
-    String[] globsAndEmails = parsePerFile(line);
-    return (globsAndEmails != null) ? globsAndEmails[1].split(COMMA, -1) : null;
+  static String[] parsePerFileOwners(String line) {
+    String[] globsAndOwners = parsePerFile(line);
+    return (globsAndOwners != null) ? globsAndOwners[1].split(COMMA, -1) : null;
   }
 
   static class Result {
@@ -184,23 +172,24 @@
     List<String> warnings; // warning messages
     List<String> errors; // error messages
     Map<String, Set<String>> owner2paths; // maps from owner email to pathGlobs
+    Set<String> noParentGlobs; // per-file dirpath+glob with "set noparent"
 
     Result() {
       stopLooking = false;
       warnings = new ArrayList<>();
       errors = new ArrayList<>();
       owner2paths = new HashMap<>();
+      noParentGlobs = new HashSet<>();
     }
 
     void append(Result r) {
       warnings.addAll(r.warnings);
-      errors.addAll(r.warnings);
+      errors.addAll(r.errors);
       stopLooking |= r.stopLooking; // included file's "set noparent" applies to the including file
       for (String key : r.owner2paths.keySet()) {
-        for (String dir : r.owner2paths.get(key)) {
-          Util.addToMap(owner2paths, key, dir);
-        }
+        Util.addAllToMap(owner2paths, key, r.owner2paths.get(key));
       }
+      noParentGlobs.addAll(r.noParentGlobs);
     }
   }
 
@@ -260,7 +249,7 @@
    */
   void parseLine(Result result, String dir, String line, int num) {
     String email;
-    String[] globsAndEmails;
+    String[] globsAndOwners;
     String[] projectAndFile;
     if (isNoParent(line)) {
       result.stopLooking = true;
@@ -268,11 +257,15 @@
       // ignore comment and empty lines.
     } else if ((email = parseEmail(line)) != null) {
       Util.addToMap(result.owner2paths, email, dir);
-    } else if ((globsAndEmails = parsePerFile(line)) != null) {
-      String[] emails = globsAndEmails[1].split(COMMA, -1);
-      for (String glob : globsAndEmails[0].split(COMMA, -1)) {
-        for (String e : emails) {
-          Util.addToMap(result.owner2paths, e, dir + glob);
+    } else if ((globsAndOwners = parsePerFile(line)) != null) {
+      String[] owners = globsAndOwners[1].split(COMMA, -1);
+      for (String glob : globsAndOwners[0].split(COMMA, -1)) {
+        for (String e : owners) {
+          if (e.equals(Parser.TOK_SET_NOPARENT)) {
+            result.noParentGlobs.add(dir + glob);
+          } else {
+            Util.addToMap(result.owner2paths, e, dir + glob);
+          }
         }
       }
     } else if (isFile(line)) {
diff --git a/src/main/resources/Documentation/syntax.md b/src/main/resources/Documentation/syntax.md
index 946b362..1470ed2 100644
--- a/src/main/resources/Documentation/syntax.md
+++ b/src/main/resources/Documentation/syntax.md
@@ -8,17 +8,19 @@
 
 ```java
 lines      := (SPACE* line? SPACE* EOL)*
-line       := "set" SPACE+ "noparent"
-           |  "per-file" SPACE+ globs SPACE* "=" SPACE* directives
+line       := comment
+           |  noparent
+           |  ownerEmail
+           |  "per-file" SPACE+ globs SPACE* "=" SPACE* owners
            |  "include" SPACE+ (project SPACE* ":" SPACE*)? filePath
-           |  comment
-           |  directive
-directives := directive (SPACE* "," SPACE* directive)*
-directive  := email
+comment    := "#" ANYCHAR*
+noparent   := "set" SPACE+ "noparent"
+ownerEmail := email
            |  "*"
+owners     := noparent
+           |  ownerEmail (SPACE* "," SPACE* ownerEmail)*
 globs      := glob (SPACE* "," SPACE* glob)*
 glob       := [a-zA-Z0-9_-*?.]+
-comment    := "#" ANYCHAR*
 email      := [^ @]+@[^ #]+
 project    := a Gerrit project name without space or column character
 filePath   := a file path name without space or column character
@@ -27,29 +29,39 @@
 SPACE      := any white space character
 ```
 
-* An OWNERS file can include another file with the "include filePath"
-  or "include project:filePath" line.
-  When the "project:" is not specified, the OWNERS file's project is used.
-  The included file is given with the "filePath".
+* An OWNERS file can include another file with the `include filePath`
+  or `include project:filePath` line.
+  When the `project:` is not specified, the OWNERS file's project is used.
+  The included file is given with the `filePath`.
 
 * If the filePath starts with "/", it is an absolute path starting from
   the project root directory. Otherwise, the filePath is added a prefix
   of the current including file directory and then searched from the
   (given) project root directory.
 
-* `per-file globs = directives` applies each `directive` only to files
-  matching any of the `globs`. Number of globs does not need to be equal
-  to the number of directives.
+* An OWNERS file inherit rules in OWNERS files of parent directories
+  by default, unless `set noparent` is specified.
 
-* A 'glob' does not contain directory path.
+* A "glob" is UNIX globbing pathname without the directory path.
+
+* `per-file globs = owners` applies `owners` only to files
+  matching any of the `globs`. Number of globs does not need to be equal
+  to the number of `ownerEmail` in `owners`.
+
+* `per-file globs = set noparent` is like `set noparent` but applies only to
+  files matching any of the `globs`. OWNERS files in parent directories
+  are not considrered for files matching those globs. Even default ownerEmails
+  specified in the current OWNERS file are not included.
+
+* Without the `per-file globs = set noparent`, all global ownerEmails also
+  apply to files matching those globs.
 
 * The email addresses should belong to registered Gerrit users.
   A group mailing address can be used as long as it is associated to
   a Gerrit user account.
 
-* The `*` directive means that no owner is specified for the directory
-  or file. Any user can approve that directory or files. All other specified
-  owner email addresses for the same directory or files are ignored.
+* The `*` is a special ownerEmail meaning that
+  any user can approve that directory or files.
 
 ### Examples
 
@@ -63,16 +75,22 @@
 
 include P1/P2:/core/OWNERS  # include file core/OWNERS of project P1/P2
 include ../base/OWNERS  # include <this_owner_file_dir>/../base/OWNERS
+include /OWNERS  # include OWNERS at root directory of this repository
 
-per-file *.c,*.cpp = x@g.com,y@g.com,z@g.com
+per-file *.c, *.cpp = x@g.com, y@g.com, z@g.com
 # x@, y@ and z@ are owners of all *.c or *.cpp files
 per-file *.c = c@g.com
 # c@, x@, y@ and z@ are owners of all *.c files
-per-file *.xml,README:*
-# no owner for *.xml and README files
+per-file *.xml,README=*,x@g.com
+# in additional to x@g.com, anyone can be owner for *.xml and README files
 
 abc@g.com  # one default owner
 xyz@g.com  # another default owner
 # abc@ and xyz@ are owners for all files in this directory,
-# except *.c, *.cpp, *.xml, and README files
+# including *.c, *.cpp, *.xml, and README files
+
+per-file *.txt,*.java = set noparent
+per-file *.txt,*.java = jj@g.com
+# Only jj@g.com is the owner of *.txt and *.java files,
+# not even abc@g.com or xyz@g.com
 ```
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/FindOwnersIT.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/FindOwnersIT.java
index 7559da9..d50a1c0 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/findowners/FindOwnersIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/FindOwnersIT.java
@@ -327,12 +327,12 @@
     String ownerA = ownerJson("a@a");
     String ownerB = ownerJson("b@b");
     String ownerC = ownerJson("c@c");
+    String ownerABC = "owners:[ " +ownerA + ", " + ownerB + ", " + ownerC;
     String ownerX = ownerJson("x@x");
-    assertThat(getOwnersResponse(c2)).contains("owners:[ " + ownerX + " ], files:[ t.c ]");
-    // Add "t.txt" file, which has new owners.
+    assertThat(getOwnersResponse(c2)).contains(ownerABC + ", " + ownerX + " ], files:[ t.c ]");
+    // Add "t.txt" file, which has only global default owners.
     PushOneCommit.Result c3 = createChange("3", "t.txt", "Test!");
-    assertThat(getOwnersResponse(c3))
-        .contains("owners:[ " + ownerA + ", " + ownerB + ", " + ownerC + " ], files:[ t.txt ]");
+    assertThat(getOwnersResponse(c3)).contains(ownerABC + " ], files:[ t.txt ]");
   }
 
   @Test
@@ -342,9 +342,31 @@
     PushOneCommit.Result c2 = addFile("2", "d1/OWNERS", "d1@g\nper-file OWNERS=d1o@g\n");
     PushOneCommit.Result c3 = addFile("3", "d2/d1/OWNERS", "d2d1@g\ninclude ../../d1/d1/OWNERS\n");
     PushOneCommit.Result c4 = addFile("4", "d2/OWNERS", "d2@g\nper-file OWNERS=d2o@g");
-    assertThat(getOwnersResponse(c1)).contains("{ ./d1/d1/OWNERS:[ d1d1o@g, d1o@g ] }");
+    // Files that match per-file globs now inherit global default owners.
+    assertThat(getOwnersResponse(c1)).contains(
+        "{ ./d1/d1/OWNERS:[ d1@g, d1d1@g, d1d1o@g, d1o@g ] }");
+    assertThat(getOwnersResponse(c2)).contains("{ ./d1/OWNERS:[ d1@g, d1o@g ] }");
+    assertThat(getOwnersResponse(c3)).contains(
+        "{ ./d2/d1/OWNERS:[ d1d1@g, d1d1o@g, d2@g, d2d1@g, d2o@g ] }");
+    assertThat(getOwnersResponse(c4)).contains("{ ./d2/OWNERS:[ d2@g, d2o@g ] }");
+  }
+
+  @Test
+  public void includePerFileNoParentTest() throws Exception {
+    // Test included file with per-file and set noparent, which affects the including file.
+    PushOneCommit.Result c1 = addFile("1", "d1/d1/OWNERS",
+        "d1d1@g\nper-file OW* = set noparent\nper-file OWNERS=d1d1o@g\n");
+    PushOneCommit.Result c2 = addFile("2", "d1/OWNERS",
+        "d1@g\nper-file OWNERS=d1o@g\nper-file * = set noparent\n");
+    PushOneCommit.Result c3 = addFile( "3", "d2/d1/OWNERS",
+        "per-file O*S=d2d1o@g\nd2d1@g\ninclude ../../d1/d1/OWNERS\n");
+    PushOneCommit.Result c4 = addFile("4",
+        "d2/OWNERS", "d2@g\nper-file OWNERS=d2o@g\nper-file *S=set  noparent \n");
+    // Files that match per-file globs with set noparent do not inherit global default owners.
+    // But include directive can include more per-file owners as in c3.
+    assertThat(getOwnersResponse(c1)).contains("{ ./d1/d1/OWNERS:[ d1d1o@g ] }");
     assertThat(getOwnersResponse(c2)).contains("{ ./d1/OWNERS:[ d1o@g ] }");
-    assertThat(getOwnersResponse(c3)).contains("{ ./d2/d1/OWNERS:[ d1d1o@g, d2o@g ] }");
+    assertThat(getOwnersResponse(c3)).contains("{ ./d2/d1/OWNERS:[ d1d1o@g, d2d1o@g ] }");
     assertThat(getOwnersResponse(c4)).contains("{ ./d2/OWNERS:[ d2o@g ] }");
   }
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java
index cc60789..f81e465 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java
@@ -144,6 +144,7 @@
               + "per-file   *.py=u2.m@g.com   \n"
               + "per-file *.c, *.java ,A*bp = u1@g.com, u1+review@g.com  ,u2.m@g.com#comment\n"
               + "  per-file *.txt = * # everyone can approve #  \n"
+              + "per-file *.java = set noparent #  \n"
               + "  set   noparent  # comment#\n");
 
   private static final ImmutableSet<String> EXPECTED_VERBOSE_OUTPUT =
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/ParserTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/ParserTest.java
index d5c31de..a26e952 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/findowners/ParserTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/ParserTest.java
@@ -71,6 +71,39 @@
   }
 
   @Test
+  public void appendResultTest() {
+    String s1 = "a@b@c";
+    String s2 = "**";
+    String e1 = testLineErrorMsg(s1);
+    String e2 = testLineErrorMsg(s2);
+    String w1 = testLineWarningMsg("w1");
+    String w2 = testLineWarningMsg("w2");
+    String b1 = "d1/*.c";
+    String b2 = "d2/*.java";
+    Parser.Result r1 = testLine(s1);
+    Parser.Result r2 = testLine(s2);
+    assertThat(r1.warnings).isEmpty();
+    assertThat(r2.warnings).isEmpty();
+    assertThat(r1.noParentGlobs).isEmpty();
+    assertThat(r2.noParentGlobs).isEmpty();
+    assertThat(r1.errors).containsExactly(e1);
+    assertThat(r2.errors).containsExactly(e2);
+    r1.warnings.add(w1);
+    r2.warnings.add(w2);
+    r1.noParentGlobs.add(b1);
+    r2.noParentGlobs.add(b2);
+    r2.append(r1);
+    assertThat(r2.warnings).containsExactly(w2, w1);
+    assertThat(r2.noParentGlobs).containsExactly(b2, b1);
+    assertThat(r1.noParentGlobs).containsExactly(b1);
+    assertThat(r2.errors).containsExactly(e2, e1);
+    r1.append(r2);
+    assertThat(r1.warnings).containsExactly(w1, w2, w1);
+    assertThat(r1.noParentGlobs).containsExactly(b2, b1); // set union
+    assertThat(r1.errors).containsExactly(e1, e2, e1);
+  }
+
+  @Test
   public void commentLineTest() {
     String[] lines = {"", "   ", "# comment #data", "#any", "  # comment"};
     for (String s : lines) {
@@ -120,22 +153,35 @@
   public void perFileGoodDirectiveTest() {
     String[] directives = {
       "abc@google.com#comment", "  *# comment", "  xyz@gmail.com # comment",
-      "a@g.com  ,  xyz@gmail.com , *  # comment", "*,*#comment", "  a@b,c@d  "
+      "a@g.com  ,  xyz@gmail.com , *  # comment", "*,*#comment", "  a@b,c@d  ",
+      "  set   noparent  ", "\tset\t\tnoparent\t"
     };
     String[] globsList = {"*", "*,*.c", "  *test*.java , *.cc, *.cpp  ", "*.bp,*.mk ,A*  "};
     for (String directive : directives) {
       for (String globs : globsList) {
         String line = "per-file " + globs + "=" + directive;
         Parser.Result result = testLine(line);
-        String[] emailList = directive.replaceAll("#.*$", "").trim().split(Parser.COMMA, -1);
+        String[] directiveList = directive.replaceAll("#.*$", "").trim().split(Parser.COMMA, -1);
         String[] globList = globs.trim().split(Parser.COMMA);
         Arrays.sort(globList);
-        for (String email : emailList) {
-          String[] paths = result.owner2paths.get(email).toArray(new String[1]);
-          assertThat(paths).hasLength(globList.length);
-          Arrays.sort(paths);
-          for (int g = 0; g < globList.length; g++) {
-            assertThat(paths[g]).isEqualTo(mockedTestDir() + globList[g]);
+        String[] owners = Parser.parsePerFileOwners(line);
+        assertThat(owners).hasLength(directiveList.length);
+        for (String email : owners) {
+          String e = email.trim();
+          assertThat(result.stopLooking).isFalse();
+          if (e.equals(Parser.TOK_SET_NOPARENT)) {
+            assertThat(result.owner2paths).isEmpty(); // no other owners in this per-file
+            assertThat(result.noParentGlobs).hasSize(globList.length);
+            for (String glob : globList) {
+              assertThat(result.noParentGlobs).contains(mockedTestDir() + glob);
+            }
+          } else {
+            String[] paths = result.owner2paths.get(e).toArray(new String[1]);
+            assertThat(paths).hasLength(globList.length);  // should not work for "set noparent"
+            Arrays.sort(paths);
+            for (int g = 0; g < globList.length; g++) {
+              assertThat(paths[g]).isEqualTo(mockedTestDir() + globList[g]);
+            }
           }
         }
       }
@@ -145,8 +191,8 @@
   @Test
   public void perFileBadDirectiveTest() {
     String[] directives = {
-      "file://OWNERS", " ** ", "a b@c .co", "a@b@c  #com", "a.<b>@zc#", " set  noparent ",
-      " , a@b  ", "a@b, , c@d  #"
+      "file://OWNERS", " ** ", "a b@c .co", "a@b@c  #com", "a.<b>@zc#",
+      " , a@b  ", "a@b, , c@d  #", "a@b, set noparent"
     };
     for (String directive : directives) {
       String line = "per-file *test*.c=" + directive;