chromium/tools/android/checkstyle/checkstyle.py

#!/usr/bin/env python3
# Copyright 2013 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Script that is used by PRESUBMIT.py to run style checks on Java files."""

import argparse
import collections
import os
import subprocess
import sys
import xml.dom.minidom


_SELF_DIR = os.path.dirname(__file__)
CHROMIUM_SRC = os.path.normpath(os.path.join(_SELF_DIR, '..', '..', '..'))
_CHECKSTYLE_ROOT = os.path.join(CHROMIUM_SRC, 'third_party', 'checkstyle',
                                'cipd', 'checkstyle-all.jar')
_JAVA_PATH = os.path.join(CHROMIUM_SRC, 'third_party', 'jdk', 'current', 'bin',
                          'java')
_STYLE_FILE = os.path.join(_SELF_DIR, 'chromium-style-5.0.xml')
_REMOVE_UNUSED_IMPORTS_PATH = os.path.join(_SELF_DIR,
                                           'remove_unused_imports.py')
_INCLUSIVE_WARNING_IDENTIFIER = 'Please use inclusive language'


class Violation(
        collections.namedtuple('Violation',
                               'file,line,column,message,severity')):
    def __str__(self):
        column = f'{self.column}:' if self.column else ''
        return f'{self.file}:{self.line}:{column} {self.message}'

    def is_warning(self):
        return self.severity == 'warning'

    def is_error(self):
        return self.severity == 'error'


def run_checkstyle(local_path, style_file, java_files):
    cmd = [
        _JAVA_PATH, '-cp', _CHECKSTYLE_ROOT,
        'com.puppycrawl.tools.checkstyle.Main', '-c', style_file, '-f', 'xml'
    ] + java_files
    result = subprocess.run(cmd, capture_output=True, check=False, text=True)

    stderr_lines = result.stderr.splitlines()
    # One line is always: "Checkstyle ends with # warnings/errors".
    if len(stderr_lines) > 1 or (stderr_lines
                                 and 'ends with' not in stderr_lines[0]):
        sys.stderr.write(result.stderr)
        sys.stderr.write(
            f'\nCheckstyle failed with returncode={result.returncode}.\n')
        sys.stderr.write('This might mean you have a syntax error\n')
        sys.exit(-1)

    try:
        root = xml.dom.minidom.parseString(result.stdout)
    except Exception:
        sys.stderr.write('Tried to parse:\n')
        sys.stderr.write(result.stdout)
        sys.stderr.write('\n')
        raise

    inclusive_files = []
    inclusive_warning = ''
    results = []
    for fileElement in root.getElementsByTagName('file'):
        filename = fileElement.attributes['name'].value
        if filename.startswith(local_path):
            filename = filename[len(local_path) + 1:]
        errors = fileElement.getElementsByTagName('error')
        for error in errors:
            severity = error.attributes['severity'].value
            if severity not in ('warning', 'error'):
                continue
            message = error.attributes['message'].value
            line = int(error.attributes['line'].value)
            column = None
            if error.hasAttribute('column'):
                column = int(error.attributes['column'].value)
            if _INCLUSIVE_WARNING_IDENTIFIER in message:
                inclusive_warning = message
                inclusive_files.append(f'{filename}:{str(line)}\n  ')
                continue
            results.append(Violation(filename, line, column, message,
                                     severity))

    if inclusive_files:
        results.append(
            Violation(
                ''.join(str(filename) for filename in inclusive_files) + '\n',
                '  ^^^ The above edited file(s) contain non-inclusive language (may be pre-existing). ^^^  ',
                '', inclusive_warning, 'warning'))

    return results


def run_presubmit(input_api, output_api, files_to_skip=None):
    # Android toolchain is only available on Linux.
    if not sys.platform.startswith('linux'):
        return []

    # Filter out non-Java files and files that were deleted.
    java_files = [
        x.AbsoluteLocalPath() for x in
        input_api.AffectedSourceFiles(lambda f: input_api.FilterSourceFile(
            f, files_to_skip=files_to_skip)) if x.LocalPath().endswith('.java')
    ]
    if not java_files:
        return []

    local_path = input_api.PresubmitLocalPath()
    violations = run_checkstyle(local_path, _STYLE_FILE, java_files)
    warnings = ['  ' + str(v) for v in violations if v.is_warning()]
    errors = ['  ' + str(v) for v in violations if v.is_error()]

    ret = []
    if warnings:
        ret.append(output_api.PresubmitPromptWarning('\n'.join(warnings)))
    if errors:
        msg = '\n'.join(errors)
        if 'Unused import:' in msg or 'Duplicate import' in msg:
            msg += """

To remove unused imports: """ + input_api.os_path.relpath(
                _REMOVE_UNUSED_IMPORTS_PATH, local_path)
        ret.append(output_api.PresubmitError(msg))
    return ret


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('java_files', nargs='+')
    args = parser.parse_args()

    violations = run_checkstyle(CHROMIUM_SRC, _STYLE_FILE, args.java_files)
    for v in violations:
        print(f'{v} ({v.severity})')

    if any(v.is_error() for v in violations):
        sys.exit(1)


if __name__ == '__main__':
    main()