• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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