blob: ad2fe7451304b1c8014b52d4aa34a5cb5775f6b4 [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.
*
* 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
// 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<>@#,]+|\\*)";
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 + ")?";
// 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;
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_NOPARENT + EOL);
// 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_PER_FILE_OWNERS.
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 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_PER_FILE_OWNERS.matcher(m.group(2).trim()).matches()) {
return null;
}
return new String[]{removeExtraSpaces(m.group(1)), removeExtraSpaces(m.group(2))};
}
static String[] parsePerFileOwners(String line) {
String[] globsAndOwners = parsePerFile(line);
return (globsAndOwners != null) ? globsAndOwners[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
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.errors);
stopLooking |= r.stopLooking; // included file's "set noparent" applies to the including file
for (String key : r.owner2paths.keySet()) {
Util.addAllToMap(owner2paths, key, r.owner2paths.get(key));
}
noParentGlobs.addAll(r.noParentGlobs);
}
}
// 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[] globsAndOwners;
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 ((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)) {
// 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);
}
}