• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2016 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Functions that implement the actual checks."""
16
17import collections
18import fnmatch
19import json
20import os
21import platform
22import re
23import sys
24
25_path = os.path.realpath(__file__ + '/../..')
26if sys.path[0] != _path:
27    sys.path.insert(0, _path)
28del _path
29
30# pylint: disable=wrong-import-position
31import rh.git
32import rh.results
33import rh.utils
34
35
36class Placeholders(object):
37    """Holder class for replacing ${vars} in arg lists.
38
39    To add a new variable to replace in config files, just add it as a @property
40    to this class using the form.  So to add support for BIRD:
41      @property
42      def var_BIRD(self):
43        return <whatever this is>
44
45    You can return either a string or an iterable (e.g. a list or tuple).
46    """
47
48    def __init__(self, diff=()):
49        """Initialize.
50
51        Args:
52          diff: The list of files that changed.
53        """
54        self.diff = diff
55
56    def expand_vars(self, args):
57        """Perform place holder expansion on all of |args|.
58
59        Args:
60          args: The args to perform expansion on.
61
62        Returns:
63          The updated |args| list.
64        """
65        all_vars = set(self.vars())
66        replacements = dict((var, self.get(var)) for var in all_vars)
67
68        ret = []
69        for arg in args:
70            if arg.endswith('${PREUPLOAD_FILES_PREFIXED}'):
71                if arg == '${PREUPLOAD_FILES_PREFIXED}':
72                    assert len(ret) > 1, ('PREUPLOAD_FILES_PREFIXED cannot be '
73                                          'the 1st or 2nd argument')
74                    prev_arg = ret[-1]
75                    ret = ret[0:-1]
76                    for file in self.get('PREUPLOAD_FILES'):
77                        ret.append(prev_arg)
78                        ret.append(file)
79                else:
80                    prefix = arg[0:-len('${PREUPLOAD_FILES_PREFIXED}')]
81                    ret.extend(
82                        prefix + file for file in self.get('PREUPLOAD_FILES'))
83            else:
84                # First scan for exact matches
85                for key, val in replacements.items():
86                    var = '${%s}' % (key,)
87                    if arg == var:
88                        if isinstance(val, str):
89                            ret.append(val)
90                        else:
91                            ret.extend(val)
92                        # We break on first hit to avoid double expansion.
93                        break
94                else:
95                    # If no exact matches, do an inline replacement.
96                    def replace(m):
97                        val = self.get(m.group(1))
98                        if isinstance(val, str):
99                            return val
100                        return ' '.join(val)
101                    ret.append(re.sub(r'\$\{(%s)\}' % ('|'.join(all_vars),),
102                                      replace, arg))
103        return ret
104
105    @classmethod
106    def vars(cls):
107        """Yield all replacement variable names."""
108        for key in dir(cls):
109            if key.startswith('var_'):
110                yield key[4:]
111
112    def get(self, var):
113        """Helper function to get the replacement |var| value."""
114        return getattr(self, 'var_%s' % (var,))
115
116    @property
117    def var_PREUPLOAD_COMMIT_MESSAGE(self):
118        """The git commit message."""
119        return os.environ.get('PREUPLOAD_COMMIT_MESSAGE', '')
120
121    @property
122    def var_PREUPLOAD_COMMIT(self):
123        """The git commit sha1."""
124        return os.environ.get('PREUPLOAD_COMMIT', '')
125
126    @property
127    def var_PREUPLOAD_FILES(self):
128        """List of files modified in this git commit."""
129        return [x.file for x in self.diff if x.status != 'D']
130
131    @property
132    def var_REPO_ROOT(self):
133        """The root of the repo checkout."""
134        return rh.git.find_repo_root()
135
136    @property
137    def var_BUILD_OS(self):
138        """The build OS (see _get_build_os_name for details)."""
139        return _get_build_os_name()
140
141
142class ExclusionScope(object):
143    """Exclusion scope for a hook.
144
145    An exclusion scope can be used to determine if a hook has been disabled for
146    a specific project.
147    """
148
149    def __init__(self, scope):
150        """Initialize.
151
152        Args:
153          scope: A list of shell-style wildcards (fnmatch) or regular
154              expression. Regular expressions must start with the ^ character.
155        """
156        self._scope = []
157        for path in scope:
158            if path.startswith('^'):
159                self._scope.append(re.compile(path))
160            else:
161                self._scope.append(path)
162
163    def __contains__(self, proj_dir):
164        """Checks if |proj_dir| matches the excluded paths.
165
166        Args:
167          proj_dir: The relative path of the project.
168        """
169        for exclusion_path in self._scope:
170            if hasattr(exclusion_path, 'match'):
171                if exclusion_path.match(proj_dir):
172                    return True
173            elif fnmatch.fnmatch(proj_dir, exclusion_path):
174                return True
175        return False
176
177
178class HookOptions(object):
179    """Holder class for hook options."""
180
181    def __init__(self, name, args, tool_paths):
182        """Initialize.
183
184        Args:
185          name: The name of the hook.
186          args: The override commandline arguments for the hook.
187          tool_paths: A dictionary with tool names to paths.
188        """
189        self.name = name
190        self._args = args
191        self._tool_paths = tool_paths
192
193    @staticmethod
194    def expand_vars(args, diff=()):
195        """Perform place holder expansion on all of |args|."""
196        replacer = Placeholders(diff=diff)
197        return replacer.expand_vars(args)
198
199    def args(self, default_args=(), diff=()):
200        """Gets the hook arguments, after performing place holder expansion.
201
202        Args:
203          default_args: The list to return if |self._args| is empty.
204          diff: The list of files that changed in the current commit.
205
206        Returns:
207          A list with arguments.
208        """
209        args = self._args
210        if not args:
211            args = default_args
212
213        return self.expand_vars(args, diff=diff)
214
215    def tool_path(self, tool_name):
216        """Gets the path in which the |tool_name| executable can be found.
217
218        This function performs expansion for some place holders.  If the tool
219        does not exist in the overridden |self._tool_paths| dictionary, the tool
220        name will be returned and will be run from the user's $PATH.
221
222        Args:
223          tool_name: The name of the executable.
224
225        Returns:
226          The path of the tool with all optional place holders expanded.
227        """
228        assert tool_name in TOOL_PATHS
229        if tool_name not in self._tool_paths:
230            return TOOL_PATHS[tool_name]
231
232        tool_path = os.path.normpath(self._tool_paths[tool_name])
233        return self.expand_vars([tool_path])[0]
234
235
236# A callable hook.
237CallableHook = collections.namedtuple('CallableHook', ('name', 'hook', 'scope'))
238
239
240def _run(cmd, **kwargs):
241    """Helper command for checks that tend to gather output."""
242    kwargs.setdefault('combine_stdout_stderr', True)
243    kwargs.setdefault('capture_output', True)
244    kwargs.setdefault('check', False)
245    # Make sure hooks run with stdin disconnected to avoid accidentally
246    # interactive tools causing pauses.
247    kwargs.setdefault('input', '')
248    return rh.utils.run(cmd, **kwargs)
249
250
251def _match_regex_list(subject, expressions):
252    """Try to match a list of regular expressions to a string.
253
254    Args:
255      subject: The string to match regexes on.
256      expressions: An iterable of regular expressions to check for matches with.
257
258    Returns:
259      Whether the passed in subject matches any of the passed in regexes.
260    """
261    for expr in expressions:
262        if re.search(expr, subject):
263            return True
264    return False
265
266
267def _filter_diff(diff, include_list, exclude_list=()):
268    """Filter out files based on the conditions passed in.
269
270    Args:
271      diff: list of diff objects to filter.
272      include_list: list of regex that when matched with a file path will cause
273          it to be added to the output list unless the file is also matched with
274          a regex in the exclude_list.
275      exclude_list: list of regex that when matched with a file will prevent it
276          from being added to the output list, even if it is also matched with a
277          regex in the include_list.
278
279    Returns:
280      A list of filepaths that contain files matched in the include_list and not
281      in the exclude_list.
282    """
283    filtered = []
284    for d in diff:
285        if (d.status != 'D' and
286                _match_regex_list(d.file, include_list) and
287                not _match_regex_list(d.file, exclude_list)):
288            # We've got a match!
289            filtered.append(d)
290    return filtered
291
292
293def _get_build_os_name():
294    """Gets the build OS name.
295
296    Returns:
297      A string in a format usable to get prebuilt tool paths.
298    """
299    system = platform.system()
300    if 'Darwin' in system or 'Macintosh' in system:
301        return 'darwin-x86'
302
303    # TODO: Add more values if needed.
304    return 'linux-x86'
305
306
307def _fixup_func_caller(cmd, **kwargs):
308    """Wraps |cmd| around a callable automated fixup.
309
310    For hooks that support automatically fixing errors after running (e.g. code
311    formatters), this function provides a way to run |cmd| as the |fixup_func|
312    parameter in HookCommandResult.
313    """
314    def wrapper():
315        result = _run(cmd, **kwargs)
316        if result.returncode not in (None, 0):
317            return result.stdout
318        return None
319    return wrapper
320
321
322def _check_cmd(hook_name, project, commit, cmd, fixup_func=None, **kwargs):
323    """Runs |cmd| and returns its result as a HookCommandResult."""
324    return [rh.results.HookCommandResult(hook_name, project, commit,
325                                         _run(cmd, **kwargs),
326                                         fixup_func=fixup_func)]
327
328
329# Where helper programs exist.
330TOOLS_DIR = os.path.realpath(__file__ + '/../../tools')
331
332def get_helper_path(tool):
333    """Return the full path to the helper |tool|."""
334    return os.path.join(TOOLS_DIR, tool)
335
336
337def check_custom(project, commit, _desc, diff, options=None, **kwargs):
338    """Run a custom hook."""
339    return _check_cmd(options.name, project, commit, options.args((), diff),
340                      **kwargs)
341
342
343def check_bpfmt(project, commit, _desc, diff, options=None):
344    """Checks that Blueprint files are formatted with bpfmt."""
345    filtered = _filter_diff(diff, [r'\.bp$'])
346    if not filtered:
347        return None
348
349    bpfmt = options.tool_path('bpfmt')
350    cmd = [bpfmt, '-l'] + options.args((), filtered)
351    ret = []
352    for d in filtered:
353        data = rh.git.get_file_content(commit, d.file)
354        result = _run(cmd, input=data)
355        if result.stdout:
356            ret.append(rh.results.HookResult(
357                'bpfmt', project, commit, error=result.stdout,
358                files=(d.file,)))
359    return ret
360
361
362def check_checkpatch(project, commit, _desc, diff, options=None):
363    """Run |diff| through the kernel's checkpatch.pl tool."""
364    tool = get_helper_path('checkpatch.pl')
365    cmd = ([tool, '-', '--root', project.dir] +
366           options.args(('--ignore=GERRIT_CHANGE_ID',), diff))
367    return _check_cmd('checkpatch.pl', project, commit, cmd,
368                      input=rh.git.get_patch(commit))
369
370
371def check_clang_format(project, commit, _desc, diff, options=None):
372    """Run git clang-format on the commit."""
373    tool = get_helper_path('clang-format.py')
374    clang_format = options.tool_path('clang-format')
375    git_clang_format = options.tool_path('git-clang-format')
376    tool_args = (['--clang-format', clang_format, '--git-clang-format',
377                  git_clang_format] +
378                 options.args(('--style', 'file', '--commit', commit), diff))
379    cmd = [tool] + tool_args
380    fixup_func = _fixup_func_caller([tool, '--fix'] + tool_args)
381    return _check_cmd('clang-format', project, commit, cmd,
382                      fixup_func=fixup_func)
383
384
385def check_google_java_format(project, commit, _desc, _diff, options=None):
386    """Run google-java-format on the commit."""
387
388    tool = get_helper_path('google-java-format.py')
389    google_java_format = options.tool_path('google-java-format')
390    google_java_format_diff = options.tool_path('google-java-format-diff')
391    tool_args = ['--google-java-format', google_java_format,
392                 '--google-java-format-diff', google_java_format_diff,
393                 '--commit', commit] + options.args()
394    cmd = [tool] + tool_args
395    fixup_func = _fixup_func_caller([tool, '--fix'] + tool_args)
396    return _check_cmd('google-java-format', project, commit, cmd,
397                      fixup_func=fixup_func)
398
399
400def check_commit_msg_bug_field(project, commit, desc, _diff, options=None):
401    """Check the commit message for a 'Bug:' line."""
402    field = 'Bug'
403    regex = r'^%s: (None|[0-9]+(, [0-9]+)*)$' % (field,)
404    check_re = re.compile(regex)
405
406    if options.args():
407        raise ValueError('commit msg %s check takes no options' % (field,))
408
409    found = []
410    for line in desc.splitlines():
411        if check_re.match(line):
412            found.append(line)
413
414    if not found:
415        error = ('Commit message is missing a "%s:" line.  It must match the\n'
416                 'following case-sensitive regex:\n\n    %s') % (field, regex)
417    else:
418        return None
419
420    return [rh.results.HookResult('commit msg: "%s:" check' % (field,),
421                                  project, commit, error=error)]
422
423
424def check_commit_msg_changeid_field(project, commit, desc, _diff, options=None):
425    """Check the commit message for a 'Change-Id:' line."""
426    field = 'Change-Id'
427    regex = r'^%s: I[a-f0-9]+$' % (field,)
428    check_re = re.compile(regex)
429
430    if options.args():
431        raise ValueError('commit msg %s check takes no options' % (field,))
432
433    found = []
434    for line in desc.splitlines():
435        if check_re.match(line):
436            found.append(line)
437
438    if not found:
439        error = ('Commit message is missing a "%s:" line.  It must match the\n'
440                 'following case-sensitive regex:\n\n    %s') % (field, regex)
441    elif len(found) > 1:
442        error = ('Commit message has too many "%s:" lines.  There can be only '
443                 'one.') % (field,)
444    else:
445        return None
446
447    return [rh.results.HookResult('commit msg: "%s:" check' % (field,),
448                                  project, commit, error=error)]
449
450
451PREBUILT_APK_MSG = """Commit message is missing required prebuilt APK
452information.  To generate the information, use the aapt tool to dump badging
453information of the APKs being uploaded, specify where the APK was built, and
454specify whether the APKs are suitable for release:
455
456    for apk in $(find . -name '*.apk' | sort); do
457        echo "${apk}"
458        ${AAPT} dump badging "${apk}" |
459            grep -iE "(package: |sdkVersion:|targetSdkVersion:)" |
460            sed -e "s/' /'\\n/g"
461        echo
462    done
463
464It must match the following case-sensitive multiline regex searches:
465
466    %s
467
468For more information, see go/platform-prebuilt and go/android-prebuilt.
469
470"""
471
472
473def check_commit_msg_prebuilt_apk_fields(project, commit, desc, diff,
474                                         options=None):
475    """Check that prebuilt APK commits contain the required lines."""
476
477    if options.args():
478        raise ValueError('prebuilt apk check takes no options')
479
480    filtered = _filter_diff(diff, [r'\.apk$'])
481    if not filtered:
482        return None
483
484    regexes = [
485        r'^package: .*$',
486        r'^sdkVersion:.*$',
487        r'^targetSdkVersion:.*$',
488        r'^Built here:.*$',
489        (r'^This build IS( NOT)? suitable for'
490         r'( preview|( preview or)? public) release'
491         r'( but IS NOT suitable for public release)?\.$')
492    ]
493
494    missing = []
495    for regex in regexes:
496        if not re.search(regex, desc, re.MULTILINE):
497            missing.append(regex)
498
499    if missing:
500        error = PREBUILT_APK_MSG % '\n    '.join(missing)
501    else:
502        return None
503
504    return [rh.results.HookResult('commit msg: "prebuilt apk:" check',
505                                  project, commit, error=error)]
506
507
508TEST_MSG = """Commit message is missing a "Test:" line.  It must match the
509following case-sensitive regex:
510
511    %s
512
513The Test: stanza is free-form and should describe how you tested your change.
514As a CL author, you'll have a consistent place to describe the testing strategy
515you use for your work. As a CL reviewer, you'll be reminded to discuss testing
516as part of your code review, and you'll more easily replicate testing when you
517patch in CLs locally.
518
519Some examples below:
520
521Test: make WITH_TIDY=1 mmma art
522Test: make test-art
523Test: manual - took a photo
524Test: refactoring CL. Existing unit tests still pass.
525
526Check the git history for more examples. It's a free-form field, so we urge
527you to develop conventions that make sense for your project. Note that many
528projects use exact test commands, which are perfectly fine.
529
530Adding good automated tests with new code is critical to our goals of keeping
531the system stable and constantly improving quality. Please use Test: to
532highlight this area of your development. And reviewers, please insist on
533high-quality Test: descriptions.
534"""
535
536
537def check_commit_msg_test_field(project, commit, desc, _diff, options=None):
538    """Check the commit message for a 'Test:' line."""
539    field = 'Test'
540    regex = r'^%s: .*$' % (field,)
541    check_re = re.compile(regex)
542
543    if options.args():
544        raise ValueError('commit msg %s check takes no options' % (field,))
545
546    found = []
547    for line in desc.splitlines():
548        if check_re.match(line):
549            found.append(line)
550
551    if not found:
552        error = TEST_MSG % (regex)
553    else:
554        return None
555
556    return [rh.results.HookResult('commit msg: "%s:" check' % (field,),
557                                  project, commit, error=error)]
558
559
560RELNOTE_MISSPELL_MSG = """Commit message contains something that looks
561similar to the "Relnote:" tag.  It must match the regex:
562
563    %s
564
565The Relnote: stanza is free-form and should describe what developers need to
566know about your change.
567
568Some examples below:
569
570Relnote: "Added a new API `Class#isBetter` to determine whether or not the
571class is better"
572Relnote: Fixed an issue where the UI would hang on a double tap.
573
574Check the git history for more examples. It's a free-form field, so we urge
575you to develop conventions that make sense for your project.
576"""
577
578RELNOTE_MISSING_QUOTES_MSG = """Commit message contains something that looks
579similar to the "Relnote:" tag but might be malformatted.  For multiline
580release notes, you need to include a starting and closing quote.
581
582Multi-line Relnote example:
583
584Relnote: "Added a new API `Class#getSize` to get the size of the class.
585This is useful if you need to know the size of the class."
586
587Single-line Relnote example:
588
589Relnote: Added a new API `Class#containsData`
590"""
591
592RELNOTE_INVALID_QUOTES_MSG = """Commit message contains something that looks
593similar to the "Relnote:" tag but might be malformatted.  If you are using
594quotes that do not mark the start or end of a Relnote, you need to escape them
595with a backslash.
596
597Non-starting/non-ending quote Relnote examples:
598
599Relnote: "Fixed an error with `Class#getBar()` where \"foo\" would be returned
600in edge cases."
601Relnote: Added a new API to handle strings like \"foo\"
602"""
603
604def check_commit_msg_relnote_field_format(project, commit, desc, _diff,
605                                          options=None):
606    """Check the commit for one correctly formatted 'Relnote:' line.
607
608    Checks the commit message for two things:
609    (1) Checks for possible misspellings of the 'Relnote:' tag.
610    (2) Ensures that multiline release notes are properly formatted with a
611    starting quote and an endling quote.
612    (3) Checks that release notes that contain non-starting or non-ending
613    quotes are escaped with a backslash.
614    """
615    field = 'Relnote'
616    regex_relnote = r'^%s:.*$' % (field,)
617    check_re_relnote = re.compile(regex_relnote, re.IGNORECASE)
618
619    if options.args():
620        raise ValueError('commit msg %s check takes no options' % (field,))
621
622    # Check 1: Check for possible misspellings of the `Relnote:` field.
623
624    # Regex for misspelled fields.
625    possible_field_misspells = {'Relnotes', 'ReleaseNote',
626                                'Rel-note', 'Rel note',
627                                'rel-notes', 'releasenotes',
628                                'release-note', 'release-notes'}
629    regex_field_misspells = r'^(%s): .*$' % (
630        '|'.join(possible_field_misspells),
631    )
632    check_re_field_misspells = re.compile(regex_field_misspells, re.IGNORECASE)
633
634    ret = []
635    for line in desc.splitlines():
636        if check_re_field_misspells.match(line):
637            error = RELNOTE_MISSPELL_MSG % (regex_relnote, )
638            ret.append(
639                rh.results.HookResult(('commit msg: "%s:" '
640                                       'tag spelling error') % (field,),
641                                      project, commit, error=error))
642
643    # Check 2: Check that multiline Relnotes are quoted.
644
645    check_re_empty_string = re.compile(r'^$')
646
647    # Regex to find other fields that could be used.
648    regex_other_fields = r'^[a-zA-Z0-9-]+:'
649    check_re_other_fields = re.compile(regex_other_fields)
650
651    desc_lines = desc.splitlines()
652    for i, cur_line in enumerate(desc_lines):
653        # Look for a Relnote tag that is before the last line and
654        # lacking any quotes.
655        if (check_re_relnote.match(cur_line) and
656                i < len(desc_lines) - 1 and
657                '"' not in cur_line):
658            next_line = desc_lines[i + 1]
659            # Check that the next line does not contain any other field
660            # and it's not an empty string.
661            if (not check_re_other_fields.findall(next_line) and
662                    not check_re_empty_string.match(next_line)):
663                ret.append(
664                    rh.results.HookResult(('commit msg: "%s:" '
665                                           'tag missing quotes') % (field,),
666                                          project, commit,
667                                          error=RELNOTE_MISSING_QUOTES_MSG))
668                break
669
670    # Check 3: Check that multiline Relnotes contain matching quotes.
671    first_quote_found = False
672    second_quote_found = False
673    for cur_line in desc_lines:
674        contains_quote = '"' in cur_line
675        contains_field = check_re_other_fields.findall(cur_line)
676        # If we have found the first quote and another field, break and fail.
677        if first_quote_found and contains_field:
678            break
679        # If we have found the first quote, this line contains a quote,
680        # and this line is not another field, break and succeed.
681        if first_quote_found and contains_quote:
682            second_quote_found = True
683            break
684        # Check that the `Relnote:` tag exists and it contains a starting quote.
685        if check_re_relnote.match(cur_line) and contains_quote:
686            first_quote_found = True
687            # A single-line Relnote containing a start and ending triple quote
688            # is valid.
689            if cur_line.count('"""') == 2:
690                second_quote_found = True
691                break
692            # A single-line Relnote containing a start and ending quote
693            # is valid.
694            if cur_line.count('"') - cur_line.count('\\"') == 2:
695                second_quote_found = True
696                break
697    if first_quote_found != second_quote_found:
698        ret.append(
699            rh.results.HookResult(('commit msg: "%s:" '
700                                   'tag missing closing quote') % (field,),
701                                  project, commit,
702                                  error=RELNOTE_MISSING_QUOTES_MSG))
703
704    # Check 4: Check that non-starting or non-ending quotes are escaped with a
705    # backslash.
706    line_needs_checking = False
707    uses_invalid_quotes = False
708    for cur_line in desc_lines:
709        if check_re_other_fields.findall(cur_line):
710            line_needs_checking = False
711        on_relnote_line = check_re_relnote.match(cur_line)
712        # Determine if we are parsing the base `Relnote:` line.
713        if on_relnote_line and '"' in cur_line:
714            line_needs_checking = True
715            # We don't think anyone will type '"""' and then forget to
716            # escape it, so we're not checking for this.
717            if '"""' in cur_line:
718                break
719        if line_needs_checking:
720            stripped_line = re.sub('^%s:' % field, '', cur_line,
721                                   flags=re.IGNORECASE).strip()
722            for i, character in enumerate(stripped_line):
723                if i == 0:
724                    # Case 1: Valid quote at the beginning of the
725                    # base `Relnote:` line.
726                    if on_relnote_line:
727                        continue
728                    # Case 2: Invalid quote at the beginning of following
729                    # lines, where we are not terminating the release note.
730                    if character == '"' and stripped_line != '"':
731                        uses_invalid_quotes = True
732                        break
733                # Case 3: Check all other cases.
734                if (character == '"'
735                        and 0 < i < len(stripped_line) - 1
736                        and stripped_line[i-1] != '"'
737                        and stripped_line[i-1] != "\\"):
738                    uses_invalid_quotes = True
739                    break
740
741    if uses_invalid_quotes:
742        ret.append(rh.results.HookResult(('commit msg: "%s:" '
743                                          'tag using unescaped '
744                                          'quotes') % (field,),
745                                         project, commit,
746                                         error=RELNOTE_INVALID_QUOTES_MSG))
747    return ret
748
749
750RELNOTE_REQUIRED_CURRENT_TXT_MSG = """\
751Commit contains a change to current.txt or public_plus_experimental_current.txt,
752but the commit message does not contain the required `Relnote:` tag.  It must
753match the regex:
754
755    %s
756
757The Relnote: stanza is free-form and should describe what developers need to
758know about your change.  If you are making infrastructure changes, you
759can set the Relnote: stanza to be "N/A" for the commit to not be included
760in release notes.
761
762Some examples:
763
764Relnote: "Added a new API `Class#isBetter` to determine whether or not the
765class is better"
766Relnote: Fixed an issue where the UI would hang on a double tap.
767Relnote: N/A
768
769Check the git history for more examples.
770"""
771
772def check_commit_msg_relnote_for_current_txt(project, commit, desc, diff,
773                                             options=None):
774    """Check changes to current.txt contain the 'Relnote:' stanza."""
775    field = 'Relnote'
776    regex = r'^%s: .+$' % (field,)
777    check_re = re.compile(regex, re.IGNORECASE)
778
779    if options.args():
780        raise ValueError('commit msg %s check takes no options' % (field,))
781
782    filtered = _filter_diff(
783        diff,
784        [r'(^|/)(public_plus_experimental_current|current)\.txt$']
785    )
786    # If the commit does not contain a change to *current.txt, then this repo
787    # hook check no longer applies.
788    if not filtered:
789        return None
790
791    found = []
792    for line in desc.splitlines():
793        if check_re.match(line):
794            found.append(line)
795
796    if not found:
797        error = RELNOTE_REQUIRED_CURRENT_TXT_MSG % (regex)
798    else:
799        return None
800
801    return [rh.results.HookResult('commit msg: "%s:" check' % (field,),
802                                  project, commit, error=error)]
803
804
805def check_cpplint(project, commit, _desc, diff, options=None):
806    """Run cpplint."""
807    # This list matches what cpplint expects.  We could run on more (like .cxx),
808    # but cpplint would just ignore them.
809    filtered = _filter_diff(diff, [r'\.(cc|h|cpp|cu|cuh)$'])
810    if not filtered:
811        return None
812
813    cpplint = options.tool_path('cpplint')
814    cmd = [cpplint] + options.args(('${PREUPLOAD_FILES}',), filtered)
815    return _check_cmd('cpplint', project, commit, cmd)
816
817
818def check_gofmt(project, commit, _desc, diff, options=None):
819    """Checks that Go files are formatted with gofmt."""
820    filtered = _filter_diff(diff, [r'\.go$'])
821    if not filtered:
822        return None
823
824    gofmt = options.tool_path('gofmt')
825    cmd = [gofmt, '-l'] + options.args((), filtered)
826    ret = []
827    for d in filtered:
828        data = rh.git.get_file_content(commit, d.file)
829        result = _run(cmd, input=data)
830        if result.stdout:
831            fixup_func = _fixup_func_caller([gofmt, '-w', d.file])
832            ret.append(rh.results.HookResult(
833                'gofmt', project, commit, error=result.stdout,
834                files=(d.file,), fixup_func=fixup_func))
835    return ret
836
837
838def check_json(project, commit, _desc, diff, options=None):
839    """Verify json files are valid."""
840    if options.args():
841        raise ValueError('json check takes no options')
842
843    filtered = _filter_diff(diff, [r'\.json$'])
844    if not filtered:
845        return None
846
847    ret = []
848    for d in filtered:
849        data = rh.git.get_file_content(commit, d.file)
850        try:
851            json.loads(data)
852        except ValueError as e:
853            ret.append(rh.results.HookResult(
854                'json', project, commit, error=str(e),
855                files=(d.file,)))
856    return ret
857
858
859def _check_pylint(project, commit, _desc, diff, extra_args=None, options=None):
860    """Run pylint."""
861    filtered = _filter_diff(diff, [r'\.py$'])
862    if not filtered:
863        return None
864
865    if extra_args is None:
866        extra_args = []
867
868    pylint = options.tool_path('pylint')
869    cmd = [
870        get_helper_path('pylint.py'),
871        '--executable-path', pylint,
872    ] + extra_args + options.args(('${PREUPLOAD_FILES}',), filtered)
873    return _check_cmd('pylint', project, commit, cmd)
874
875
876def check_pylint2(project, commit, desc, diff, options=None):
877    """Run pylint through Python 2."""
878    return _check_pylint(project, commit, desc, diff, options=options)
879
880
881def check_pylint3(project, commit, desc, diff, options=None):
882    """Run pylint through Python 3."""
883    return _check_pylint(project, commit, desc, diff,
884                         extra_args=['--py3'],
885                         options=options)
886
887
888def check_rustfmt(project, commit, _desc, diff, options=None):
889    """Run "rustfmt --check" on diffed rust files"""
890    filtered = _filter_diff(diff, [r'\.rs$'])
891    if not filtered:
892        return None
893
894    rustfmt = options.tool_path('rustfmt')
895    cmd = [rustfmt] + options.args((), filtered)
896    ret = []
897    for d in filtered:
898        data = rh.git.get_file_content(commit, d.file)
899        result = _run(cmd, input=data)
900        # If the parsing failed, stdout will contain enough details on the
901        # location of the error.
902        if result.returncode:
903            ret.append(rh.results.HookResult(
904                'rustfmt', project, commit, error=result.stdout,
905                files=(d.file,)))
906            continue
907        # TODO(b/164111102): rustfmt stable does not support --check on stdin.
908        # If no error is reported, compare stdin with stdout.
909        if data != result.stdout:
910            msg = ('To fix, please run: %s' %
911                   rh.shell.cmd_to_str(cmd + [d.file]))
912            ret.append(rh.results.HookResult(
913                'rustfmt', project, commit, error=msg,
914                files=(d.file,)))
915    return ret
916
917
918def check_xmllint(project, commit, _desc, diff, options=None):
919    """Run xmllint."""
920    # XXX: Should we drop most of these and probe for <?xml> tags?
921    extensions = frozenset((
922        'dbus-xml',  # Generated DBUS interface.
923        'dia',       # File format for Dia.
924        'dtd',       # Document Type Definition.
925        'fml',       # Fuzzy markup language.
926        'form',      # Forms created by IntelliJ GUI Designer.
927        'fxml',      # JavaFX user interfaces.
928        'glade',     # Glade user interface design.
929        'grd',       # GRIT translation files.
930        'iml',       # Android build modules?
931        'kml',       # Keyhole Markup Language.
932        'mxml',      # Macromedia user interface markup language.
933        'nib',       # OS X Cocoa Interface Builder.
934        'plist',     # Property list (for OS X).
935        'pom',       # Project Object Model (for Apache Maven).
936        'rng',       # RELAX NG schemas.
937        'sgml',      # Standard Generalized Markup Language.
938        'svg',       # Scalable Vector Graphics.
939        'uml',       # Unified Modeling Language.
940        'vcproj',    # Microsoft Visual Studio project.
941        'vcxproj',   # Microsoft Visual Studio project.
942        'wxs',       # WiX Transform File.
943        'xhtml',     # XML HTML.
944        'xib',       # OS X Cocoa Interface Builder.
945        'xlb',       # Android locale bundle.
946        'xml',       # Extensible Markup Language.
947        'xsd',       # XML Schema Definition.
948        'xsl',       # Extensible Stylesheet Language.
949    ))
950
951    filtered = _filter_diff(diff, [r'\.(%s)$' % '|'.join(extensions)])
952    if not filtered:
953        return None
954
955    # TODO: Figure out how to integrate schema validation.
956    # XXX: Should we use python's XML libs instead?
957    cmd = ['xmllint'] + options.args(('${PREUPLOAD_FILES}',), filtered)
958
959    return _check_cmd('xmllint', project, commit, cmd)
960
961
962def check_android_test_mapping(project, commit, _desc, diff, options=None):
963    """Verify Android TEST_MAPPING files are valid."""
964    if options.args():
965        raise ValueError('Android TEST_MAPPING check takes no options')
966    filtered = _filter_diff(diff, [r'(^|.*/)TEST_MAPPING$'])
967    if not filtered:
968        return None
969
970    testmapping_format = options.tool_path('android-test-mapping-format')
971    testmapping_args = ['--commit', commit]
972    cmd = [testmapping_format] + options.args(
973        (project.dir, '${PREUPLOAD_FILES}'), filtered) + testmapping_args
974    return _check_cmd('android-test-mapping-format', project, commit, cmd)
975
976
977def check_aidl_format(project, commit, _desc, diff, options=None):
978    """Checks that AIDL files are formatted with aidl-format."""
979    # All *.aidl files except for those under aidl_api directory.
980    filtered = _filter_diff(diff, [r'\.aidl$'], [r'/aidl_api/'])
981    if not filtered:
982        return None
983    aidl_format = options.tool_path('aidl-format')
984    cmd = [aidl_format, '-d'] + options.args((), filtered)
985    ret = []
986    for d in filtered:
987        data = rh.git.get_file_content(commit, d.file)
988        result = _run(cmd, input=data)
989        if result.stdout:
990            fixup_func = _fixup_func_caller([aidl_format, '-w', d.file])
991            ret.append(rh.results.HookResult(
992                'aidl-format', project, commit, error=result.stdout,
993                files=(d.file,), fixup_func=fixup_func))
994    return ret
995
996
997# Hooks that projects can opt into.
998# Note: Make sure to keep the top level README.md up to date when adding more!
999BUILTIN_HOOKS = {
1000    'aidl_format': check_aidl_format,
1001    'android_test_mapping_format': check_android_test_mapping,
1002    'bpfmt': check_bpfmt,
1003    'checkpatch': check_checkpatch,
1004    'clang_format': check_clang_format,
1005    'commit_msg_bug_field': check_commit_msg_bug_field,
1006    'commit_msg_changeid_field': check_commit_msg_changeid_field,
1007    'commit_msg_prebuilt_apk_fields': check_commit_msg_prebuilt_apk_fields,
1008    'commit_msg_test_field': check_commit_msg_test_field,
1009    'commit_msg_relnote_field_format': check_commit_msg_relnote_field_format,
1010    'commit_msg_relnote_for_current_txt':
1011        check_commit_msg_relnote_for_current_txt,
1012    'cpplint': check_cpplint,
1013    'gofmt': check_gofmt,
1014    'google_java_format': check_google_java_format,
1015    'jsonlint': check_json,
1016    'pylint': check_pylint2,
1017    'pylint2': check_pylint2,
1018    'pylint3': check_pylint3,
1019    'rustfmt': check_rustfmt,
1020    'xmllint': check_xmllint,
1021}
1022
1023# Additional tools that the hooks can call with their default values.
1024# Note: Make sure to keep the top level README.md up to date when adding more!
1025TOOL_PATHS = {
1026    'aidl-format': 'aidl-format',
1027    'android-test-mapping-format':
1028        os.path.join(TOOLS_DIR, 'android_test_mapping_format.py'),
1029    'bpfmt': 'bpfmt',
1030    'clang-format': 'clang-format',
1031    'cpplint': os.path.join(TOOLS_DIR, 'cpplint.py'),
1032    'git-clang-format': 'git-clang-format',
1033    'gofmt': 'gofmt',
1034    'google-java-format': 'google-java-format',
1035    'google-java-format-diff': 'google-java-format-diff.py',
1036    'pylint': 'pylint',
1037    'rustfmt': 'rustfmt',
1038}
1039