| #!/usr/bin/env python2 | |
| import json | |
| import logging | |
| import os.path | |
| import re | |
| import pprint | |
| import sys | |
| __author__ = 'lawrence' | |
| MAX_TRAILING_SPACES_MSGS_PER_FILE = 1000 | |
| MAX_MIXED_TABS_MSGS_PER_FILE = 1000 | |
| MAX_SPACING_MSGS_PER_FILE = 1000 | |
| MAX_INDENT_MSGS_PER_FILE = 1000 | |
| INDENT_UNKNOWN = 0 | |
| INDENT_SPACES = 1 | |
| INDENT_TABS = 2 | |
| class ChangedFile: | |
| SOURCE_EXT = ['.c', '.cpp', '.cc', '.h', '.java', '.mk', '.xml'] | |
| C_JAVA_EXT = ['.c', '.cpp', '.java'] | |
| TEXT_RESOURCE_EXT = ['.rc', '.prop', '.te', '.kl', '.cfg', '.conf', '.dtd'] | |
| BINARY_RESOURCE_EXT = ['.txt', '.so', '.ko', '.apk', '.png', '.jpg', '.jpeg', '.gif'] | |
| def __init__(self, filename=None, is_new=False, mode=None): | |
| self.filename = filename | |
| self.file_ext = None | |
| if filename: | |
| self.on_update_filename() | |
| self.is_new = is_new | |
| self.mode = mode | |
| self.formattable_carriage_returns = False | |
| self.comments = {} | |
| def on_update_filename(self): | |
| if not self.filename: | |
| logging.error("couldn't get filename") | |
| return | |
| self.file_ext = os.path.splitext(self.filename)[1].lower() | |
| def is_source(self): | |
| #if self.file_ext in self.SOURCE_EXT: | |
| # return True | |
| return True # return true directly, doesn't check file type | |
| if self.filename: | |
| b = os.path.basename(self.filename) | |
| if (b and ( | |
| b.startswith("Kconfig") or | |
| b == "Makefile")): | |
| return True | |
| return False | |
| def is_binary_resource(self): | |
| if self.file_ext in self.BINARY_RESOURCE_EXT: | |
| return True | |
| return False | |
| def is_text_resource(self): | |
| if self.file_ext in self.TEXT_RESOURCE_EXT: | |
| return True | |
| return False | |
| def has_errors(self): | |
| if self.comments: | |
| return True | |
| # same as add_file_comments: | |
| if self.mode == 755 and self.should_not_be_executable(): | |
| return True | |
| if self.formattable_carriage_returns and self.should_not_have_carriage_return(): | |
| return True | |
| return False | |
| def should_check_line_diff(self): | |
| if self.is_source() or self.is_text_resource(): | |
| return True | |
| return False | |
| def should_not_be_executable(self): | |
| return self.is_source() or self.is_text_resource() or self.is_binary_resource() | |
| def should_not_have_carriage_return(self): | |
| if self.is_new: | |
| if self.is_source() or self.is_text_resource(): | |
| return True | |
| return False | |
| def should_check_statement_spacing(self): | |
| if self.file_ext in self.C_JAVA_EXT: | |
| return True | |
| return False | |
| def should_check_indent(self): | |
| if self.file_ext in self.C_JAVA_EXT: | |
| return True | |
| return False | |
| def add_file_comments(self): | |
| if self.mode == 755 and self.should_not_be_executable(): | |
| self.append_comment(0, "{} file should not be executable".format(self.file_ext)) | |
| if self.formattable_carriage_returns and self.should_not_have_carriage_return(): | |
| self.append_comment(0, "{} file should not have carriage returns (DOS line endings)".format(self.file_ext)) | |
| def append_comment(self, line, msg): | |
| if line in self.comments: | |
| self.comments[line] += "\n\n" | |
| self.comments[line] += msg | |
| else: | |
| self.comments[line] = msg | |
| # types of files/checks | |
| # source/resource: | |
| # should be non-executable (new/changed source + .ko, etc) | |
| # source: | |
| # should not have carriage return (new source + text resources) | |
| # text resource: | |
| # should not have trailing spaces (source + text resources) | |
| # should not have mixed spaces/tabs (source + text resources) | |
| # source + syntax | |
| # should have space in if statements (source c/java) | |
| # added line indent should match context | |
| # *could be imported code - warn only..? | |
| def check(filename): | |
| """ | |
| Checks unified diff. | |
| :param filename: diff file to check | |
| :return: 0 on patch errors, 1 on no patch errors, < 0 on other errors | |
| """ | |
| if not filename: | |
| return -1 | |
| try: | |
| with open(filename) as fp: | |
| return check_fp(fp) | |
| except OSError: | |
| logging.error(" failed to open? OSError %s", filename) | |
| return -2 | |
| except IOError: | |
| logging.error(" failed to open? IOError %s", filename) | |
| return -3 | |
| return -4 | |
| # TODO split checks into separate functions | |
| def check_fp(fp): | |
| file_sections = [] | |
| f = None | |
| check_lines = False | |
| check_statement_spacing = False | |
| trailing_sp_msg_count = 0 | |
| mixed_tabs_msg_count = 0 | |
| spacing_msg_count = 0 | |
| in_line_diff = False | |
| section_line_start = 0 | |
| section_line_start_err = False | |
| cur_line = 0 | |
| for line in fp: | |
| if line.startswith("diff"): | |
| if f and f.has_errors(): | |
| f.add_file_comments() | |
| file_sections.append(f) | |
| # start of new file | |
| f = ChangedFile() | |
| check_lines = False | |
| trailing_sp_msg_count = 0 | |
| mixed_tabs_msg_count = 0 | |
| spacing_msg_count = 0 | |
| indent_msg_count = 0 | |
| context_indent = INDENT_UNKNOWN | |
| in_line_diff = False | |
| # get filename | |
| # might fail on paths like "dir b/file.txt" | |
| m = re.match(r"^diff --git a/(.*) b/.*", line) | |
| if m: | |
| f.filename = m.group(1) | |
| f.on_update_filename() | |
| check_lines = f.should_check_line_diff() | |
| check_statement_spacing = f.should_check_statement_spacing() | |
| check_indent = f.should_check_indent() | |
| elif line.startswith("new file mode "): | |
| f.is_new = True | |
| if line.startswith("100755", len("new file mode ")): | |
| f.mode = 755 | |
| elif line.startswith("new mode 100755"): | |
| f.mode = 755 | |
| elif f and not f.filename and line.startswith("+++ b/"): | |
| # get filename if previously failed for some reason | |
| f.filename = line[len("+++ b/"):].rstrip('\r\n ') | |
| f.on_update_filename() | |
| check_lines = f.should_check_line_diff() | |
| check_statement_spacing = f.should_check_statement_spacing() | |
| check_indent = f.should_check_indent() | |
| else: | |
| if not check_lines: | |
| continue | |
| if line.startswith("@@ "): | |
| # keep track of line numbers | |
| # @@ -584,7 +681,7 @@ | |
| m = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)?\ @@", line) | |
| try: | |
| section_line_start = int(m.group(1)) | |
| except ValueError: | |
| logging.error("failed to parse section line start") | |
| section_line_start_err = True | |
| in_line_diff = True | |
| cur_line = section_line_start - 1 # next line is the start | |
| continue | |
| if in_line_diff: | |
| # keep track of line numbers | |
| if line[0] in ' +': | |
| cur_line += 1 | |
| # get last context line's indent | |
| if line[0] == " ": | |
| if line.startswith(" ", 1): | |
| context_indent = INDENT_SPACES | |
| elif line.startswith("\t", 1): | |
| context_indent = INDENT_TABS | |
| if line[0] == '+' and line[1] != '+': | |
| if check_lines and not section_line_start_err: | |
| if (f.is_new and | |
| not f.formattable_carriage_returns and | |
| line[-2] == '\r'): | |
| f.formattable_carriage_returns = True | |
| if trailing_sp_msg_count < MAX_TRAILING_SPACES_MSGS_PER_FILE: | |
| if (line.endswith(" \n") or | |
| line.endswith(" \r\n") or | |
| line.endswith("\t\n") or | |
| line.endswith("\t\r\n")): | |
| f.append_comment(cur_line, "trailing spaces") | |
| trailing_sp_msg_count += 1 | |
| if mixed_tabs_msg_count < MAX_MIXED_TABS_MSGS_PER_FILE: | |
| if re.match(r" +\t", line[1:]) or re.match(r"\t+ +\t", line[1:]): | |
| # tab space can be correct, but not space tab and tab space tab | |
| f.append_comment(cur_line, "possibly incorrect mixed spaces then tabs indentation") | |
| mixed_tabs_msg_count += 1 | |
| if check_statement_spacing and spacing_msg_count < MAX_SPACING_MSGS_PER_FILE: | |
| m = re.match(r"\s*(if|while|for|switch)", line[1:]) | |
| if (m): | |
| # line starts with if|while|for|switch | |
| keyword = m.group(1) | |
| # check parenthesis/brace spacing. if( -> if ( or ){ -> ) { | |
| m = re.match(r"\s*(?:if|while|for|switch)( ?)\(.*\)( ?)(\{?)", line[1:]) | |
| if (m): | |
| keyword_sp, brace_space, brace = m.groups() | |
| if keyword_sp != ' ' or ( | |
| brace == '{' and brace_space != ' '): | |
| f.append_comment(cur_line, | |
| "%s (...) %s // spacing around parenthesis" % (keyword, brace)) | |
| spacing_msg_count += 1 | |
| # check binary operator spacing on if|while line | |
| # cpplint.py: match = Search(r'[^<>=!\s](==|!=|<=|>=|\|\|)[^<>=!\s,;\)]', line | |
| if keyword in ['if', 'while']: | |
| m = re.search(r"[^<>=!\s](==|!=|<=|>=|\|\||&&)[^<>=!\s,;\)]", line[1:]) | |
| if (m): | |
| f.append_comment(cur_line, "spacing around %s" % m.group(1)) | |
| spacing_msg_count += 1 | |
| continue | |
| # do{ -> do { | |
| elif re.match(r"\s*do\{", line[1:]): | |
| f.append_comment(cur_line, 'do {') | |
| spacing_msg_count += 1 | |
| if check_indent and indent_msg_count < MAX_INDENT_MSGS_PER_FILE: | |
| if ((context_indent == INDENT_SPACES and line.startswith("\t", 1)) or | |
| (context_indent == INDENT_TABS and line.startswith(" ", 1))): | |
| f.append_comment(cur_line, "make sure indent style matches rest of file") | |
| indent_msg_count += 1 | |
| if f and f.has_errors(): | |
| f.add_file_comments() | |
| file_sections.append(f) | |
| if False: | |
| for f in file_sections: | |
| assert isinstance(f, ChangedFile) | |
| if f.comments: | |
| print f.filename | |
| pprint.pprint(f.comments) | |
| print "---" | |
| json_ret = file_comments_to_review(file_sections) | |
| if json_ret: | |
| print json_ret | |
| return 0 | |
| else: | |
| return 1 | |
| REPLY_MSG = "This is an automated message.\n\nIf you think these comments are incorrect, they can be ignored." | |
| POSITIVE_REPLY_MSG = "This is an automated message.\n\nNo problems found." | |
| def file_comments_to_array(changed_file): | |
| """ | |
| Return a list of comments for a CommentInput entry from a ChangedFile | |
| :param changed_file: a ChangedFile object | |
| :return: a list of comments for CommentInput | |
| """ | |
| ret = [] | |
| assert isinstance(changed_file, ChangedFile) | |
| for line, msg in changed_file.comments.iteritems(): | |
| ret.append({"line": line, | |
| "message": msg}) | |
| return ret | |
| def file_comments_to_review(changed_files): | |
| """ | |
| Create a JSON ReviewInput from a list of ChangedFiles | |
| :param changed_files: list of ChangedFiles | |
| :return: JSON ReviewInput string | |
| """ | |
| review = {} | |
| review['comments'] = {} | |
| for f in changed_files: | |
| if f.filename and f.comments: | |
| c = file_comments_to_array(f) | |
| if not c: | |
| logging.error("no comments for file") | |
| review['comments'][f.filename] = c | |
| if review['comments']: | |
| review['message'] = REPLY_MSG | |
| review['labels'] = {'Verified': -1} | |
| review['notify'] = 'OWNER' | |
| else: | |
| del review['comments'] | |
| review['message'] = POSITIVE_REPLY_MSG | |
| review['labels'] = {'Verified': +1} | |
| review['notify'] = 'OWNER' | |
| #return json.dumps(review, indent=2) | |
| return json.dumps(review) | |
| if __name__ == '__main__': | |
| if len(sys.argv) == 2: | |
| r = check(sys.argv[1]) | |
| sys.exit(r) | |
| else: | |
| sys.stderr.write("%s <patch filename>\n" % sys.argv[0]) | |
| sys.exit(0) | |