1# Copyright 2019 The ANGLE Project Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4"""Top-level presubmit script for code generation. 5 6See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts 7for more details on the presubmit API built into depot_tools. 8""" 9 10import itertools 11import os 12import re 13import shutil 14import subprocess 15import sys 16import tempfile 17import textwrap 18import pathlib 19 20# This line is 'magic' in that git-cl looks for it to decide whether to 21# use Python3 instead of Python2 when running the code in this file. 22USE_PYTHON3 = True 23 24# Fragment of a regular expression that matches C/C++ and Objective-C++ implementation files and headers. 25_IMPLEMENTATION_AND_HEADER_EXTENSIONS = r'\.(c|cc|cpp|cxx|mm|h|hpp|hxx)$' 26 27# Fragment of a regular expression that matches C++ and Objective-C++ header files. 28_HEADER_EXTENSIONS = r'\.(h|hpp|hxx)$' 29 30_PRIMARY_EXPORT_TARGETS = [ 31 '//:libEGL', 32 '//:libGLESv1_CM', 33 '//:libGLESv2', 34 '//:translator', 35] 36 37 38def _SplitIntoMultipleCommits(description_text): 39 paragraph_split_pattern = r"(?m)(^\s*$\n)" 40 multiple_paragraphs = re.split(paragraph_split_pattern, description_text) 41 multiple_commits = [""] 42 change_id_pattern = re.compile(r"(?m)^Change-Id: [a-zA-Z0-9]*$") 43 for paragraph in multiple_paragraphs: 44 multiple_commits[-1] += paragraph 45 if change_id_pattern.search(paragraph): 46 multiple_commits.append("") 47 if multiple_commits[-1] == "": 48 multiple_commits.pop() 49 return multiple_commits 50 51 52def _CheckCommitMessageFormatting(input_api, output_api): 53 54 def _IsLineBlank(line): 55 return line.isspace() or line == "" 56 57 def _PopBlankLines(lines, reverse=False): 58 if reverse: 59 while len(lines) > 0 and _IsLineBlank(lines[-1]): 60 lines.pop() 61 else: 62 while len(lines) > 0 and _IsLineBlank(lines[0]): 63 lines.pop(0) 64 65 def _IsTagLine(line): 66 return ":" in line 67 68 def _CheckTabInCommit(lines): 69 return all([line.find("\t") == -1 for line in lines]) 70 71 allowlist_strings = ['Revert', 'Roll', 'Manual roll', 'Reland', 'Re-land'] 72 summary_linelength_warning_lower_limit = 65 73 summary_linelength_warning_upper_limit = 70 74 description_linelength_limit = 72 75 76 git_output = input_api.change.DescriptionText() 77 78 multiple_commits = _SplitIntoMultipleCommits(git_output) 79 errors = [] 80 81 for k in range(len(multiple_commits)): 82 commit_msg_lines = multiple_commits[k].splitlines() 83 commit_number = len(multiple_commits) - k 84 commit_tag = "Commit " + str(commit_number) + ":" 85 commit_msg_line_numbers = {} 86 for i in range(len(commit_msg_lines)): 87 commit_msg_line_numbers[commit_msg_lines[i]] = i + 1 88 _PopBlankLines(commit_msg_lines, True) 89 _PopBlankLines(commit_msg_lines, False) 90 allowlisted = False 91 if len(commit_msg_lines) > 0: 92 for allowlist_string in allowlist_strings: 93 if commit_msg_lines[0].startswith(allowlist_string): 94 allowlisted = True 95 break 96 if allowlisted: 97 continue 98 99 if not _CheckTabInCommit(commit_msg_lines): 100 errors.append( 101 output_api.PresubmitError(commit_tag + "Tabs are not allowed in commit message.")) 102 103 # the tags paragraph is at the end of the message 104 # the break between the tags paragraph is the first line without ":" 105 # this is sufficient because if a line is blank, it will not have ":" 106 last_paragraph_line_count = 0 107 while len(commit_msg_lines) > 0 and _IsTagLine(commit_msg_lines[-1]): 108 last_paragraph_line_count += 1 109 commit_msg_lines.pop() 110 if last_paragraph_line_count == 0: 111 errors.append( 112 output_api.PresubmitError( 113 commit_tag + 114 "Please ensure that there are tags (e.g., Bug:, Test:) in your description.")) 115 if len(commit_msg_lines) > 0: 116 if not _IsLineBlank(commit_msg_lines[-1]): 117 output_api.PresubmitError(commit_tag + 118 "Please ensure that there exists 1 blank line " + 119 "between tags and description body.") 120 else: 121 # pop the blank line between tag paragraph and description body 122 commit_msg_lines.pop() 123 if len(commit_msg_lines) > 0 and _IsLineBlank(commit_msg_lines[-1]): 124 errors.append( 125 output_api.PresubmitError( 126 commit_tag + 'Please ensure that there exists only 1 blank line ' 127 'between tags and description body.')) 128 # pop all the remaining blank lines between tag and description body 129 _PopBlankLines(commit_msg_lines, True) 130 if len(commit_msg_lines) == 0: 131 errors.append( 132 output_api.PresubmitError(commit_tag + 133 'Please ensure that your description summary' 134 ' and description body are not blank.')) 135 continue 136 137 if summary_linelength_warning_lower_limit <= len(commit_msg_lines[0]) \ 138 <= summary_linelength_warning_upper_limit: 139 errors.append( 140 output_api.PresubmitPromptWarning( 141 commit_tag + "Your description summary should be on one line of " + 142 str(summary_linelength_warning_lower_limit - 1) + " or less characters.")) 143 elif len(commit_msg_lines[0]) > summary_linelength_warning_upper_limit: 144 errors.append( 145 output_api.PresubmitError( 146 commit_tag + "Please ensure that your description summary is on one line of " + 147 str(summary_linelength_warning_lower_limit - 1) + " or less characters.")) 148 commit_msg_lines.pop(0) # get rid of description summary 149 if len(commit_msg_lines) == 0: 150 continue 151 if not _IsLineBlank(commit_msg_lines[0]): 152 errors.append( 153 output_api.PresubmitError(commit_tag + 154 'Please ensure the summary is only 1 line and ' 155 'there is 1 blank line between the summary ' 156 'and description body.')) 157 else: 158 commit_msg_lines.pop(0) # pop first blank line 159 if len(commit_msg_lines) == 0: 160 continue 161 if _IsLineBlank(commit_msg_lines[0]): 162 errors.append( 163 output_api.PresubmitError(commit_tag + 164 'Please ensure that there exists only 1 blank line ' 165 'between description summary and description body.')) 166 # pop all the remaining blank lines between 167 # description summary and description body 168 _PopBlankLines(commit_msg_lines) 169 170 # loop through description body 171 while len(commit_msg_lines) > 0: 172 line = commit_msg_lines.pop(0) 173 # lines starting with 4 spaces, quotes or lines without space(urls) 174 # are exempt from length check 175 if line.startswith(" ") or line.startswith("> ") or " " not in line: 176 continue 177 if len(line) > description_linelength_limit: 178 errors.append( 179 output_api.PresubmitError( 180 commit_tag + 'Line ' + str(commit_msg_line_numbers[line]) + 181 ' is too long.\n' + '"' + line + '"\n' + 'Please wrap it to ' + 182 str(description_linelength_limit) + ' characters. ' + 183 "Lines without spaces or lines starting with 4 spaces are exempt.")) 184 break 185 return errors 186 187 188def _CheckChangeHasBugField(input_api, output_api): 189 """Requires that the changelist have a Bug: field from a known project.""" 190 bugs = input_api.change.BugsFromDescription() 191 if not bugs: 192 return [ 193 output_api.PresubmitError('Please ensure that your description contains:\n' 194 '"Bug: angleproject:[bug number]"\n' 195 'directly above the Change-Id tag.') 196 ] 197 198 # The bug must be in the form of "project:number". None is also accepted, which is used by 199 # rollers as well as in very minor changes. 200 if len(bugs) == 1 and bugs[0] == 'None': 201 return [] 202 203 projects = [ 204 'angleproject:', 'chromium:', 'dawn:', 'fuchsia:', 'skia:', 'swiftshader:', 'tint:', 'b/' 205 ] 206 bug_regex = re.compile(r"([a-z]+[:/])(\d+)") 207 errors = [] 208 extra_help = None 209 210 for bug in bugs: 211 if bug == 'None': 212 errors.append( 213 output_api.PresubmitError('Invalid bug tag "None" in presence of other bug tags.')) 214 continue 215 216 match = re.match(bug_regex, bug) 217 if match == None or bug != match.group(0) or match.group(1) not in projects: 218 errors.append(output_api.PresubmitError('Incorrect bug tag "' + bug + '".')) 219 if not extra_help: 220 extra_help = output_api.PresubmitError('Acceptable format is:\n\n' 221 ' Bug: project:bugnumber\n\n' 222 'Acceptable projects are:\n\n ' + 223 '\n '.join(projects)) 224 225 if extra_help: 226 errors.append(extra_help) 227 228 return errors 229 230 231def _CheckCodeGeneration(input_api, output_api): 232 233 class Msg(output_api.PresubmitError): 234 """Specialized error message""" 235 236 def __init__(self, message, **kwargs): 237 super(output_api.PresubmitError, self).__init__( 238 message, 239 long_text='Please ensure your ANGLE repositiory is synced to tip-of-tree\n' 240 'and all ANGLE DEPS are fully up-to-date by running gclient sync.\n' 241 '\n' 242 'If that fails, run scripts/run_code_generation.py to refresh generated hashes.\n' 243 '\n' 244 'If you are building ANGLE inside Chromium you must bootstrap ANGLE\n' 245 'before gclient sync. See the DevSetup documentation for more details.\n', 246 **kwargs) 247 248 code_gen_path = input_api.os_path.join(input_api.PresubmitLocalPath(), 249 'scripts/run_code_generation.py') 250 cmd_name = 'run_code_generation' 251 cmd = [input_api.python3_executable, code_gen_path, '--verify-no-dirty'] 252 test_cmd = input_api.Command(name=cmd_name, cmd=cmd, kwargs={}, message=Msg) 253 if input_api.verbose: 254 print('Running ' + cmd_name) 255 return input_api.RunTests([test_cmd]) 256 257 258# Taken directly from Chromium's PRESUBMIT.py 259def _CheckNewHeaderWithoutGnChange(input_api, output_api): 260 """Checks that newly added header files have corresponding GN changes. 261 Note that this is only a heuristic. To be precise, run script: 262 build/check_gn_headers.py. 263 """ 264 265 def headers(f): 266 return input_api.FilterSourceFile(f, files_to_check=(r'.+%s' % _HEADER_EXTENSIONS,)) 267 268 new_headers = [] 269 for f in input_api.AffectedSourceFiles(headers): 270 if f.Action() != 'A': 271 continue 272 new_headers.append(f.LocalPath()) 273 274 def gn_files(f): 275 return input_api.FilterSourceFile(f, files_to_check=(r'.+\.gn',)) 276 277 all_gn_changed_contents = '' 278 for f in input_api.AffectedSourceFiles(gn_files): 279 for _, line in f.ChangedContents(): 280 all_gn_changed_contents += line 281 282 problems = [] 283 for header in new_headers: 284 basename = input_api.os_path.basename(header) 285 if basename not in all_gn_changed_contents: 286 problems.append(header) 287 288 if problems: 289 return [ 290 output_api.PresubmitPromptWarning( 291 'Missing GN changes for new header files', 292 items=sorted(problems), 293 long_text='Please double check whether newly added header files need ' 294 'corresponding changes in gn or gni files.\nThis checking is only a ' 295 'heuristic. Run build/check_gn_headers.py to be precise.\n' 296 'Read https://crbug.com/661774 for more info.') 297 ] 298 return [] 299 300 301def _CheckExportValidity(input_api, output_api): 302 outdir = tempfile.mkdtemp() 303 # shell=True is necessary on Windows, as otherwise subprocess fails to find 304 # either 'gn' or 'vpython3' even if they are findable via PATH. 305 use_shell = input_api.is_windows 306 try: 307 try: 308 subprocess.check_output(['gn', 'gen', outdir], shell=use_shell) 309 except subprocess.CalledProcessError as e: 310 return [ 311 output_api.PresubmitError( 312 'Unable to run gn gen for export_targets.py: %s' % e.output) 313 ] 314 export_target_script = os.path.join(input_api.PresubmitLocalPath(), 'scripts', 315 'export_targets.py') 316 try: 317 subprocess.check_output( 318 ['vpython3', export_target_script, outdir] + _PRIMARY_EXPORT_TARGETS, 319 stderr=subprocess.STDOUT, 320 shell=use_shell) 321 except subprocess.CalledProcessError as e: 322 if input_api.is_committing: 323 return [output_api.PresubmitError('export_targets.py failed: %s' % e.output)] 324 return [ 325 output_api.PresubmitPromptWarning( 326 'export_targets.py failed, this may just be due to your local checkout: %s' % 327 e.output) 328 ] 329 return [] 330 finally: 331 shutil.rmtree(outdir) 332 333 334def _CheckTabsInSourceFiles(input_api, output_api): 335 """Forbids tab characters in source files due to a WebKit repo requirement.""" 336 337 def implementation_and_headers_including_third_party(f): 338 # Check third_party files too, because WebKit's checks don't make exceptions. 339 return input_api.FilterSourceFile( 340 f, 341 files_to_check=(r'.+%s' % _IMPLEMENTATION_AND_HEADER_EXTENSIONS,), 342 files_to_skip=[f for f in input_api.DEFAULT_FILES_TO_SKIP if not "third_party" in f]) 343 344 files_with_tabs = [] 345 for f in input_api.AffectedSourceFiles(implementation_and_headers_including_third_party): 346 for (num, line) in f.ChangedContents(): 347 if '\t' in line: 348 files_with_tabs.append(f) 349 break 350 351 if files_with_tabs: 352 return [ 353 output_api.PresubmitError( 354 'Tab characters in source files.', 355 items=sorted(files_with_tabs), 356 long_text= 357 'Tab characters are forbidden in ANGLE source files because WebKit\'s Subversion\n' 358 'repository does not allow tab characters in source files.\n' 359 'Please remove tab characters from these files.') 360 ] 361 return [] 362 363 364# https://stackoverflow.com/a/196392 365def is_ascii(s): 366 return all(ord(c) < 128 for c in s) 367 368 369def _CheckNonAsciiInSourceFiles(input_api, output_api): 370 """Forbids non-ascii characters in source files.""" 371 372 def implementation_and_headers(f): 373 return input_api.FilterSourceFile( 374 f, files_to_check=(r'.+%s' % _IMPLEMENTATION_AND_HEADER_EXTENSIONS,)) 375 376 files_with_non_ascii = [] 377 for f in input_api.AffectedSourceFiles(implementation_and_headers): 378 for (num, line) in f.ChangedContents(): 379 if not is_ascii(line): 380 files_with_non_ascii.append("%s: %s" % (f, line)) 381 break 382 383 if files_with_non_ascii: 384 return [ 385 output_api.PresubmitError( 386 'Non-ASCII characters in source files.', 387 items=sorted(files_with_non_ascii), 388 long_text='Non-ASCII characters are forbidden in ANGLE source files.\n' 389 'Please remove non-ASCII characters from these files.') 390 ] 391 return [] 392 393 394def _CheckCommentBeforeTestInTestFiles(input_api, output_api): 395 """Require a comment before TEST_P() and other tests.""" 396 397 def test_files(f): 398 return input_api.FilterSourceFile( 399 f, files_to_check=(r'^src/tests/.+\.cpp$', r'^src/.+_unittest\.cpp$')) 400 401 tests_with_no_comment = [] 402 for f in input_api.AffectedSourceFiles(test_files): 403 diff = f.GenerateScmDiff() 404 last_line_was_comment = False 405 for line in diff.splitlines(): 406 # Skip removed lines 407 if line.startswith('-'): 408 continue 409 410 new_line_is_comment = line.startswith(' //') or line.startswith('+//') 411 new_line_is_test_declaration = ( 412 line.startswith('+TEST_P(') or line.startswith('+TEST(') or 413 line.startswith('+TYPED_TEST(')) 414 415 if new_line_is_test_declaration and not last_line_was_comment: 416 tests_with_no_comment.append(line[1:]) 417 418 last_line_was_comment = new_line_is_comment 419 420 if tests_with_no_comment: 421 return [ 422 output_api.PresubmitError( 423 'Tests without comment.', 424 items=sorted(tests_with_no_comment), 425 long_text='ANGLE requires a comment describing what a test does.') 426 ] 427 return [] 428 429 430def _CheckShaderVersionInShaderLangHeader(input_api, output_api): 431 """Requires an update to ANGLE_SH_VERSION when ShaderLang.h or ShaderVars.h change.""" 432 433 def headers(f): 434 return input_api.FilterSourceFile( 435 f, 436 files_to_check=(r'^include/GLSLANG/ShaderLang.h$', r'^include/GLSLANG/ShaderVars.h$')) 437 438 headers_changed = input_api.AffectedSourceFiles(headers) 439 if len(headers_changed) == 0: 440 return [] 441 442 # Skip this check for reverts and rolls. Unlike 443 # _CheckCommitMessageFormatting, relands are still checked because the 444 # original change might have incremented the version correctly, but the 445 # rebase over a new version could accidentally remove that (because another 446 # change in the meantime identically incremented it). 447 git_output = input_api.change.DescriptionText() 448 multiple_commits = _SplitIntoMultipleCommits(git_output) 449 for commit in multiple_commits: 450 if commit.startswith('Revert') or commit.startswith('Roll'): 451 return [] 452 453 diffs = '\n'.join(f.GenerateScmDiff() for f in headers_changed) 454 versions = dict(re.findall(r'^([-+])#define ANGLE_SH_VERSION\s+(\d+)', diffs, re.M)) 455 456 if len(versions) != 2 or int(versions['+']) <= int(versions['-']): 457 return [ 458 output_api.PresubmitError( 459 'ANGLE_SH_VERSION should be incremented when ShaderLang.h or ShaderVars.h change.', 460 ) 461 ] 462 return [] 463 464 465def _CheckGClientExists(input_api, output_api, search_limit=None): 466 presubmit_path = pathlib.Path(input_api.PresubmitLocalPath()) 467 468 for current_path in itertools.chain([presubmit_path], presubmit_path.parents): 469 gclient_path = current_path.joinpath('.gclient') 470 if gclient_path.exists() and gclient_path.is_file(): 471 return [] 472 # search_limit parameter is used in unit tests to prevent searching all the way to root 473 # directory for reproducibility. 474 elif search_limit != None and current_path == search_limit: 475 break 476 477 return [ 478 output_api.PresubmitError( 479 'Missing .gclient file.', 480 long_text=textwrap.fill( 481 width=100, 482 text='The top level directory of the repository must contain a .gclient file.' 483 ' You can follow the steps outlined in the link below to get set up for ANGLE' 484 ' development:') + 485 '\n\nhttps://chromium.googlesource.com/angle/angle/+/refs/heads/main/doc/DevSetup.md') 486 ] 487 488 489def CheckChangeOnUpload(input_api, output_api): 490 results = [] 491 results.extend(_CheckTabsInSourceFiles(input_api, output_api)) 492 results.extend(_CheckNonAsciiInSourceFiles(input_api, output_api)) 493 results.extend(_CheckCommentBeforeTestInTestFiles(input_api, output_api)) 494 results.extend(_CheckShaderVersionInShaderLangHeader(input_api, output_api)) 495 results.extend(_CheckCodeGeneration(input_api, output_api)) 496 results.extend(_CheckChangeHasBugField(input_api, output_api)) 497 results.extend(input_api.canned_checks.CheckChangeHasDescription(input_api, output_api)) 498 results.extend(_CheckNewHeaderWithoutGnChange(input_api, output_api)) 499 results.extend(_CheckExportValidity(input_api, output_api)) 500 results.extend( 501 input_api.canned_checks.CheckPatchFormatted( 502 input_api, output_api, result_factory=output_api.PresubmitError)) 503 results.extend(_CheckCommitMessageFormatting(input_api, output_api)) 504 results.extend(_CheckGClientExists(input_api, output_api)) 505 506 return results 507 508 509def CheckChangeOnCommit(input_api, output_api): 510 return CheckChangeOnUpload(input_api, output_api) 511