Bo Lv | 72d0e90 | 2023-01-02 14:27:34 +0000 | [diff] [blame^] | 1 | #!/usr/bin/env python2 |
| 2 | |
| 3 | import json |
| 4 | import logging |
| 5 | import os.path |
| 6 | import re |
| 7 | import pprint |
| 8 | import sys |
| 9 | |
| 10 | __author__ = 'lawrence' |
| 11 | |
| 12 | MAX_TRAILING_SPACES_MSGS_PER_FILE = 1000 |
| 13 | MAX_MIXED_TABS_MSGS_PER_FILE = 1000 |
| 14 | MAX_SPACING_MSGS_PER_FILE = 1000 |
| 15 | MAX_INDENT_MSGS_PER_FILE = 1000 |
| 16 | |
| 17 | INDENT_UNKNOWN = 0 |
| 18 | INDENT_SPACES = 1 |
| 19 | INDENT_TABS = 2 |
| 20 | |
| 21 | class ChangedFile: |
| 22 | SOURCE_EXT = ['.c', '.cpp', '.cc', '.h', '.java', '.mk', '.xml'] |
| 23 | C_JAVA_EXT = ['.c', '.cpp', '.java'] |
| 24 | TEXT_RESOURCE_EXT = ['.rc', '.prop', '.te', '.kl', '.cfg', '.conf', '.dtd'] |
| 25 | BINARY_RESOURCE_EXT = ['.txt', '.so', '.ko', '.apk', '.png', '.jpg', '.jpeg', '.gif'] |
| 26 | |
| 27 | def __init__(self, filename=None, is_new=False, mode=None): |
| 28 | self.filename = filename |
| 29 | self.file_ext = None |
| 30 | if filename: |
| 31 | self.on_update_filename() |
| 32 | self.is_new = is_new |
| 33 | self.mode = mode |
| 34 | self.formattable_carriage_returns = False |
| 35 | self.comments = {} |
| 36 | |
| 37 | def on_update_filename(self): |
| 38 | if not self.filename: |
| 39 | logging.error("couldn't get filename") |
| 40 | return |
| 41 | self.file_ext = os.path.splitext(self.filename)[1].lower() |
| 42 | |
| 43 | def is_source(self): |
| 44 | #if self.file_ext in self.SOURCE_EXT: |
| 45 | # return True |
| 46 | return True # return true directly, doesn't check file type |
| 47 | if self.filename: |
| 48 | b = os.path.basename(self.filename) |
| 49 | if (b and ( |
| 50 | b.startswith("Kconfig") or |
| 51 | b == "Makefile")): |
| 52 | return True |
| 53 | return False |
| 54 | |
| 55 | def is_binary_resource(self): |
| 56 | if self.file_ext in self.BINARY_RESOURCE_EXT: |
| 57 | return True |
| 58 | return False |
| 59 | |
| 60 | def is_text_resource(self): |
| 61 | if self.file_ext in self.TEXT_RESOURCE_EXT: |
| 62 | return True |
| 63 | return False |
| 64 | |
| 65 | def has_errors(self): |
| 66 | if self.comments: |
| 67 | return True |
| 68 | # same as add_file_comments: |
| 69 | if self.mode == 755 and self.should_not_be_executable(): |
| 70 | return True |
| 71 | if self.formattable_carriage_returns and self.should_not_have_carriage_return(): |
| 72 | return True |
| 73 | return False |
| 74 | |
| 75 | def should_check_line_diff(self): |
| 76 | if self.is_source() or self.is_text_resource(): |
| 77 | return True |
| 78 | return False |
| 79 | |
| 80 | def should_not_be_executable(self): |
| 81 | return self.is_source() or self.is_text_resource() or self.is_binary_resource() |
| 82 | |
| 83 | def should_not_have_carriage_return(self): |
| 84 | if self.is_new: |
| 85 | if self.is_source() or self.is_text_resource(): |
| 86 | return True |
| 87 | return False |
| 88 | |
| 89 | def should_check_statement_spacing(self): |
| 90 | if self.file_ext in self.C_JAVA_EXT: |
| 91 | return True |
| 92 | return False |
| 93 | |
| 94 | def should_check_indent(self): |
| 95 | if self.file_ext in self.C_JAVA_EXT: |
| 96 | return True |
| 97 | return False |
| 98 | |
| 99 | def add_file_comments(self): |
| 100 | if self.mode == 755 and self.should_not_be_executable(): |
| 101 | self.append_comment(0, "{} file should not be executable".format(self.file_ext)) |
| 102 | if self.formattable_carriage_returns and self.should_not_have_carriage_return(): |
| 103 | self.append_comment(0, "{} file should not have carriage returns (DOS line endings)".format(self.file_ext)) |
| 104 | |
| 105 | def append_comment(self, line, msg): |
| 106 | if line in self.comments: |
| 107 | self.comments[line] += "\n\n" |
| 108 | self.comments[line] += msg |
| 109 | else: |
| 110 | self.comments[line] = msg |
| 111 | |
| 112 | |
| 113 | # types of files/checks |
| 114 | # source/resource: |
| 115 | # should be non-executable (new/changed source + .ko, etc) |
| 116 | # source: |
| 117 | # should not have carriage return (new source + text resources) |
| 118 | # text resource: |
| 119 | # should not have trailing spaces (source + text resources) |
| 120 | # should not have mixed spaces/tabs (source + text resources) |
| 121 | # source + syntax |
| 122 | # should have space in if statements (source c/java) |
| 123 | # added line indent should match context |
| 124 | # *could be imported code - warn only..? |
| 125 | |
| 126 | |
| 127 | def check(filename): |
| 128 | """ |
| 129 | Checks unified diff. |
| 130 | :param filename: diff file to check |
| 131 | :return: 0 on patch errors, 1 on no patch errors, < 0 on other errors |
| 132 | """ |
| 133 | if not filename: |
| 134 | return -1 |
| 135 | |
| 136 | try: |
| 137 | with open(filename) as fp: |
| 138 | return check_fp(fp) |
| 139 | except OSError: |
| 140 | logging.error(" failed to open? OSError %s", filename) |
| 141 | return -2 |
| 142 | except IOError: |
| 143 | logging.error(" failed to open? IOError %s", filename) |
| 144 | return -3 |
| 145 | return -4 |
| 146 | |
| 147 | |
| 148 | # TODO split checks into separate functions |
| 149 | def check_fp(fp): |
| 150 | file_sections = [] |
| 151 | f = None |
| 152 | check_lines = False |
| 153 | check_statement_spacing = False |
| 154 | trailing_sp_msg_count = 0 |
| 155 | mixed_tabs_msg_count = 0 |
| 156 | spacing_msg_count = 0 |
| 157 | in_line_diff = False |
| 158 | section_line_start = 0 |
| 159 | section_line_start_err = False |
| 160 | cur_line = 0 |
| 161 | for line in fp: |
| 162 | if line.startswith("diff"): |
| 163 | if f and f.has_errors(): |
| 164 | f.add_file_comments() |
| 165 | file_sections.append(f) |
| 166 | # start of new file |
| 167 | f = ChangedFile() |
| 168 | check_lines = False |
| 169 | trailing_sp_msg_count = 0 |
| 170 | mixed_tabs_msg_count = 0 |
| 171 | spacing_msg_count = 0 |
| 172 | indent_msg_count = 0 |
| 173 | context_indent = INDENT_UNKNOWN |
| 174 | in_line_diff = False |
| 175 | |
| 176 | # get filename |
| 177 | # might fail on paths like "dir b/file.txt" |
| 178 | m = re.match(r"^diff --git a/(.*) b/.*", line) |
| 179 | if m: |
| 180 | f.filename = m.group(1) |
| 181 | f.on_update_filename() |
| 182 | check_lines = f.should_check_line_diff() |
| 183 | check_statement_spacing = f.should_check_statement_spacing() |
| 184 | check_indent = f.should_check_indent() |
| 185 | elif line.startswith("new file mode "): |
| 186 | f.is_new = True |
| 187 | if line.startswith("100755", len("new file mode ")): |
| 188 | f.mode = 755 |
| 189 | elif line.startswith("new mode 100755"): |
| 190 | f.mode = 755 |
| 191 | elif f and not f.filename and line.startswith("+++ b/"): |
| 192 | # get filename if previously failed for some reason |
| 193 | f.filename = line[len("+++ b/"):].rstrip('\r\n ') |
| 194 | f.on_update_filename() |
| 195 | check_lines = f.should_check_line_diff() |
| 196 | check_statement_spacing = f.should_check_statement_spacing() |
| 197 | check_indent = f.should_check_indent() |
| 198 | else: |
| 199 | if not check_lines: |
| 200 | continue |
| 201 | if line.startswith("@@ "): |
| 202 | # keep track of line numbers |
| 203 | # @@ -584,7 +681,7 @@ |
| 204 | m = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)?\ @@", line) |
| 205 | try: |
| 206 | section_line_start = int(m.group(1)) |
| 207 | except ValueError: |
| 208 | logging.error("failed to parse section line start") |
| 209 | section_line_start_err = True |
| 210 | in_line_diff = True |
| 211 | cur_line = section_line_start - 1 # next line is the start |
| 212 | continue |
| 213 | if in_line_diff: |
| 214 | # keep track of line numbers |
| 215 | if line[0] in ' +': |
| 216 | cur_line += 1 |
| 217 | # get last context line's indent |
| 218 | if line[0] == " ": |
| 219 | if line.startswith(" ", 1): |
| 220 | context_indent = INDENT_SPACES |
| 221 | elif line.startswith("\t", 1): |
| 222 | context_indent = INDENT_TABS |
| 223 | if line[0] == '+' and line[1] != '+': |
| 224 | if check_lines and not section_line_start_err: |
| 225 | if (f.is_new and |
| 226 | not f.formattable_carriage_returns and |
| 227 | line[-2] == '\r'): |
| 228 | f.formattable_carriage_returns = True |
| 229 | |
| 230 | if trailing_sp_msg_count < MAX_TRAILING_SPACES_MSGS_PER_FILE: |
| 231 | if (line.endswith(" \n") or |
| 232 | line.endswith(" \r\n") or |
| 233 | line.endswith("\t\n") or |
| 234 | line.endswith("\t\r\n")): |
| 235 | f.append_comment(cur_line, "trailing spaces") |
| 236 | trailing_sp_msg_count += 1 |
| 237 | |
| 238 | if mixed_tabs_msg_count < MAX_MIXED_TABS_MSGS_PER_FILE: |
| 239 | if re.match(r" +\t", line[1:]) or re.match(r"\t+ +\t", line[1:]): |
| 240 | # tab space can be correct, but not space tab and tab space tab |
| 241 | f.append_comment(cur_line, "possibly incorrect mixed spaces then tabs indentation") |
| 242 | mixed_tabs_msg_count += 1 |
| 243 | |
| 244 | if check_statement_spacing and spacing_msg_count < MAX_SPACING_MSGS_PER_FILE: |
| 245 | m = re.match(r"\s*(if|while|for|switch)", line[1:]) |
| 246 | if (m): |
| 247 | # line starts with if|while|for|switch |
| 248 | keyword = m.group(1) |
| 249 | # check parenthesis/brace spacing. if( -> if ( or ){ -> ) { |
| 250 | m = re.match(r"\s*(?:if|while|for|switch)( ?)\(.*\)( ?)(\{?)", line[1:]) |
| 251 | if (m): |
| 252 | keyword_sp, brace_space, brace = m.groups() |
| 253 | if keyword_sp != ' ' or ( |
| 254 | brace == '{' and brace_space != ' '): |
| 255 | f.append_comment(cur_line, |
| 256 | "%s (...) %s // spacing around parenthesis" % (keyword, brace)) |
| 257 | spacing_msg_count += 1 |
| 258 | |
| 259 | # check binary operator spacing on if|while line |
| 260 | # cpplint.py: match = Search(r'[^<>=!\s](==|!=|<=|>=|\|\|)[^<>=!\s,;\)]', line |
| 261 | if keyword in ['if', 'while']: |
| 262 | m = re.search(r"[^<>=!\s](==|!=|<=|>=|\|\||&&)[^<>=!\s,;\)]", line[1:]) |
| 263 | if (m): |
| 264 | f.append_comment(cur_line, "spacing around %s" % m.group(1)) |
| 265 | spacing_msg_count += 1 |
| 266 | continue |
| 267 | # do{ -> do { |
| 268 | elif re.match(r"\s*do\{", line[1:]): |
| 269 | f.append_comment(cur_line, 'do {') |
| 270 | spacing_msg_count += 1 |
| 271 | |
| 272 | if check_indent and indent_msg_count < MAX_INDENT_MSGS_PER_FILE: |
| 273 | if ((context_indent == INDENT_SPACES and line.startswith("\t", 1)) or |
| 274 | (context_indent == INDENT_TABS and line.startswith(" ", 1))): |
| 275 | f.append_comment(cur_line, "make sure indent style matches rest of file") |
| 276 | indent_msg_count += 1 |
| 277 | |
| 278 | if f and f.has_errors(): |
| 279 | f.add_file_comments() |
| 280 | file_sections.append(f) |
| 281 | |
| 282 | if False: |
| 283 | for f in file_sections: |
| 284 | assert isinstance(f, ChangedFile) |
| 285 | if f.comments: |
| 286 | print f.filename |
| 287 | pprint.pprint(f.comments) |
| 288 | print "---" |
| 289 | json_ret = file_comments_to_review(file_sections) |
| 290 | if json_ret: |
| 291 | print json_ret |
| 292 | return 0 |
| 293 | else: |
| 294 | return 1 |
| 295 | |
| 296 | REPLY_MSG = "This is an automated message.\n\nIf you think these comments are incorrect, they can be ignored." |
| 297 | POSITIVE_REPLY_MSG = "This is an automated message.\n\nNo problems found." |
| 298 | |
| 299 | def file_comments_to_array(changed_file): |
| 300 | """ |
| 301 | Return a list of comments for a CommentInput entry from a ChangedFile |
| 302 | :param changed_file: a ChangedFile object |
| 303 | :return: a list of comments for CommentInput |
| 304 | """ |
| 305 | ret = [] |
| 306 | assert isinstance(changed_file, ChangedFile) |
| 307 | for line, msg in changed_file.comments.iteritems(): |
| 308 | ret.append({"line": line, |
| 309 | "message": msg}) |
| 310 | return ret |
| 311 | |
| 312 | def file_comments_to_review(changed_files): |
| 313 | """ |
| 314 | Create a JSON ReviewInput from a list of ChangedFiles |
| 315 | :param changed_files: list of ChangedFiles |
| 316 | :return: JSON ReviewInput string |
| 317 | """ |
| 318 | review = {} |
| 319 | review['comments'] = {} |
| 320 | for f in changed_files: |
| 321 | if f.filename and f.comments: |
| 322 | |
| 323 | c = file_comments_to_array(f) |
| 324 | if not c: |
| 325 | logging.error("no comments for file") |
| 326 | review['comments'][f.filename] = c |
| 327 | if review['comments']: |
| 328 | review['message'] = REPLY_MSG |
| 329 | review['labels'] = {'Verified': -1} |
| 330 | review['notify'] = 'OWNER' |
| 331 | else: |
| 332 | del review['comments'] |
| 333 | review['message'] = POSITIVE_REPLY_MSG |
| 334 | review['labels'] = {'Verified': +1} |
| 335 | review['notify'] = 'OWNER' |
| 336 | #return json.dumps(review, indent=2) |
| 337 | return json.dumps(review) |
| 338 | |
| 339 | if __name__ == '__main__': |
| 340 | if len(sys.argv) == 2: |
| 341 | r = check(sys.argv[1]) |
| 342 | sys.exit(r) |
| 343 | else: |
| 344 | sys.stderr.write("%s <patch filename>\n" % sys.argv[0]) |
| 345 | sys.exit(0) |
| 346 | |