blob: 317bea7faa7a83e171caad3802b4923727a1b845 [file] [log] [blame]
// Copyright (C) 2017 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.findowners;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.server.git.GitRepositoryManager;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 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.
*/
class Parser {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
// Globs and emails are separated by commas with optional spaces around a comma.
protected static final String COMMA = "[\\s]*,[\\s]*"; // used in unit tests
// Separator for project and file paths in an include line.
private static final String COLUMN = "[\\s]*:[\\s]*"; // project:file
private static final String BOL = "^[\\s]*"; // begin-of-line
private static final String EOL = "[\\s]*(#.*)?$"; // end-of-line
private static final String GLOB = "[^\\s,=]+"; // a file glob
// TODO: have a more precise email address pattern.
private static final String EMAIL_OR_STAR = "([^\\s<>@,]+@[^\\s<>@#,]+|\\*)";
// A Gerrit project name followed by a column and optional spaces.
private static final String PROJECT_NAME = "([^\\s:]+" + COLUMN + ")?";
// A relative or absolute file path name without any column or space character.
private static final String FILE_PATH = "([^\\s:]+)";
private static final String PROJECT_AND_FILE = PROJECT_NAME + FILE_PATH;
// 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);
// 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 + ")*)$");
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.
private static final Pattern PAT_PER_FILE =
Pattern.compile(BOL + "per-file[\\s]+([^=#]+)=[\\s]*([^#]+)" + EOL);
// A parser keeps current repoManager, project, branch, included file path, and debug/trace logs.
private GitRepositoryManager repoManager;
private String branch; // All owners files are read from the same branch.
private Deque<String[]> includeStack; // Keeps include stack of [projectName,filePath].
private Set<String> included; // Keeps included files of projectName:filePath
private List<String> logs; // Keeps debug/trace messages.
Parser(GitRepositoryManager repoManager, String project, String branch, String file) {
init(repoManager, project, branch, file, new ArrayList<>());
}
Parser(GitRepositoryManager repoManager,
String project, String branch, String file, List<String> logs) {
init(repoManager, project, branch, file, logs);
}
private void init(GitRepositoryManager repoManager,
String project, String branch, String file, List<String> logs) {
this.repoManager = repoManager;
this.branch = branch;
this.logs = logs;
includeStack = new ArrayDeque<>();
included = new HashSet<>();
pushProjectFilePaths(project, normalizedRepoDirFilePath(".", file));
}
static boolean isComment(String line) {
return PAT_COMMENT.matcher(line).matches();
}
static boolean isFile(String line) {
return PAT_FILE.matcher(line).matches();
}
static boolean isInclude(String line) {
return PAT_INCLUDE.matcher(line).matches();
}
static boolean isGlobs(String line) {
return PAT_GLOBS.matcher(line).matches();
}
static boolean isNoParent(String line) {
return PAT_NO_PARENT.matcher(line).matches();
}
static String parseEmail(String line) {
Matcher m = Parser.PAT_EMAIL.matcher(line);
return m.matches() ? m.group(1).trim() : null;
}
static String[] parseInclude(String project, String line) {
Matcher m = Parser.PAT_INCLUDE.matcher(line);
if (!m.matches()) {
return null;
}
String[] parts = new String[]{m.group(1), m.group(2).trim()};
if (parts[0] != null && parts[0].length() > 0) {
// parts[0] has project name followed by ':'
parts[0] = parts[0].split(COLUMN, -1)[0].trim();
} else {
parts[0] = project; // default project name
}
return parts;
}
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()) {
return null;
}
return new String[]{m.group(1).trim(), m.group(2).trim()};
}
static String[] parsePerFileEmails(String line) {
String[] globsAndEmails = parsePerFile(line);
return (globsAndEmails != null) ? globsAndEmails[1].split(COMMA, -1) : null;
}
static class Result {
boolean stopLooking; // if this file contains set noparent
List<String> warnings; // warning messages
List<String> errors; // error messages
Map<String, Set<String>> owner2paths; // maps from owner email to pathGlobs
Result() {
stopLooking = false;
warnings = new ArrayList<>();
errors = new ArrayList<>();
owner2paths = new HashMap<>();
}
void append(Result r) {
warnings.addAll(r.warnings);
errors.addAll(r.warnings);
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);
}
}
}
}
// Parse an OWNERS file or included file content in lines.
// "dir" is the directory that contains "changed files" of a CL,
// not necessarily the OWNERS or included file directory.
// "owners" listed in lines control changed files in 'dir' not
// necessrily the files in the directory containing "lines".
Result parseFile(String dir, String[] lines) {
Result result = new Result();
int n = 0;
for (String line : lines) {
parseLine(result, dir, line, ++n);
}
return result;
}
private String currentProject() {
return includeStack.peek()[0];
}
private String currentFilePath() {
return includeStack.peek()[1];
}
private String combineProjectAndFile(String project, String file) {
return project + ":" + file;
}
private void pushProjectFilePaths(String project, String file) {
includeStack.push(new String[]{project, file});
included.add(combineProjectAndFile(project, file));
}
private void popProjectFilePaths() {
includeStack.pop();
}
private String normalizedRepoDirFilePath(String dir, String path) {
try {
return Util.normalizedRepoDirFilePath(dir, path);
} catch (IOException e) {
String msg = "Fail to normalized path " + dir + " / " + path;
logger.atSevere().withCause(e).log(msg);
logs.add(msg + ":" + e.getMessage());
return dir + "/" + path;
}
}
/**
* Parse a line in OWNERS file and add info to OwnersDb.
*
* @param result a Result object to keep parsed info.
* @param dir the path to OWNERS file directory.
* @param line the source line.
* @param num the line number.
*/
void parseLine(Result result, String dir, String line, int num) {
String email;
String[] globsAndEmails;
String[] projectAndFile;
if (isNoParent(line)) {
result.stopLooking = true;
} else if (isComment(line)) {
// 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 (isFile(line)) {
// file: directive is parsed but ignored.
result.warnings.add(warningMsg(currentFilePath(), num, "ignored", line));
logs.add("parseLine:file");
} else if ((projectAndFile = parseInclude(currentProject(), line)) != null) {
String project = projectAndFile[0];
String file = projectAndFile[1];
String includePath = combineProjectAndFile(project, file);
// Like C/C++ #include, when f1 includes f2 with a relative path projectAndFile[1],
// use f1's directory, not 'dir', as the base for relative path.
// 'dir' is the directory of OWNERS file, which might include f1 indirectly.
String repoFile = normalizedRepoDirFilePath(Util.getParentDir(currentFilePath()), file);
String repoProjectFile = combineProjectAndFile(project, repoFile);
if (included.contains(repoProjectFile)) {
logs.add("parseLine:skip:include:" + includePath);
} else {
pushProjectFilePaths(project, repoFile);
logs.add("parseLine:include:" + includePath);
String content =
OwnersDb.getRepoFile(repoManager, project, branch, repoFile, logs);
if (content != null && !content.isEmpty()) {
result.append(parseFile(dir, content.split("\\R+")));
} else {
logs.add("parseLine:include:(empty)");
}
popProjectFilePaths();
}
} else {
result.errors.add(errorMsg(currentFilePath(), num, "ignored unknown line", line));
}
}
private static String createMsgLine(String prefix, String file, int n, String msg, String line) {
return prefix + file + ":" + n + ": " + msg + ": [" + line + "]";
}
static String errorMsg(String file, int n, String msg, String line) {
return createMsgLine("Error: ", file, n, msg, line);
}
static String warningMsg(String file, int n, String msg, String line) {
return createMsgLine("Warning: ", file, n, msg, line);
}
}