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