#!/usr/bin/env python3 # # Copyright 2015, The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # """Script that is used by developers to run style checks on Java files.""" from __future__ import print_function import argparse import errno import os import shutil import subprocess import sys import tempfile import xml.dom.minidom import gitlint.git as git def _FindFoldersContaining(root, wanted): """Recursively finds directories that have a file with the given name. Args: root: Root folder to start the search from. wanted: The filename that we are looking for. Returns: List of folders that has a file with the given name """ if not root: return [] if os.path.islink(root): return [] result = [] for file_name in os.listdir(root): file_path = os.path.join(root, file_name) if os.path.isdir(file_path): sub_result = _FindFoldersContaining(file_path, wanted) result.extend(sub_result) else: if file_name == wanted: result.append(root) return result MAIN_DIRECTORY = os.path.normpath(os.path.dirname(__file__)) CHECKSTYLE_JAR = os.path.join(MAIN_DIRECTORY, 'checkstyle.jar') CHECKSTYLE_STYLE = os.path.join(MAIN_DIRECTORY, 'android-style.xml') FORCED_RULES = ['com.puppycrawl.tools.checkstyle.checks.imports.ImportOrderCheck', 'com.puppycrawl.tools.checkstyle.checks.imports.UnusedImportsCheck'] SKIPPED_RULES_FOR_TEST_FILES = ['com.puppycrawl.tools.checkstyle.checks.javadoc.JavadocTypeCheck', 'com.puppycrawl.tools.checkstyle.checks.javadoc.JavadocMethodCheck'] SUBPATH_FOR_TEST_FILES = ['/tests/', '/test/', '/androidTest/', '/perftests/', '/gts-tests/', '/hostsidetests/', '/jvmTest/', '/robotests/'] SUBPATH_FOR_TEST_DATA_FILES = _FindFoldersContaining(git.repository_root(), 'IGNORE_CHECKSTYLE') ERROR_UNCOMMITTED = 'You need to commit all modified files before running Checkstyle\n' ERROR_UNTRACKED = 'You have untracked java files that are not being checked:\n' def RunCheckstyleOnFiles(java_files, classpath=CHECKSTYLE_JAR, config_xml=CHECKSTYLE_STYLE): """Runs Checkstyle checks on a given set of java_files. Args: java_files: A list of files to check. classpath: The colon-delimited list of JARs in the classpath. config_xml: Path of the checkstyle XML configuration file. Returns: A tuple of errors and warnings. """ print('Running Checkstyle on inputted files') java_files = list(map(os.path.abspath, java_files)) stdout = _ExecuteCheckstyle(java_files, classpath, config_xml) (errors, warnings) = _ParseAndFilterOutput(stdout) _PrintErrorsAndWarnings(errors, warnings) return errors, warnings def RunCheckstyleOnACommit(commit, classpath=CHECKSTYLE_JAR, config_xml=CHECKSTYLE_STYLE, file_whitelist=None): """Runs Checkstyle checks on a given commit. It will run Checkstyle on the changed Java files in a specified commit SHA-1 and if that is None it will fallback to check the latest commit of the currently checked out branch. Args: commit: A full 40 character SHA-1 of a commit to check. classpath: The colon-delimited list of JARs in the classpath. config_xml: Path of the checkstyle XML configuration file. file_whitelist: A list of whitelisted file paths that should be checked. Returns: A tuple of errors and warnings. """ if not git.repository_root(): print('FAILURE: not inside a git repository') sys.exit(1) explicit_commit = commit is not None if not explicit_commit: _WarnIfUntrackedFiles() commit = git.last_commit() print('Running Checkstyle on %s commit' % commit) commit_modified_files = _GetModifiedFiles(commit, explicit_commit) commit_modified_files = _FilterFiles(commit_modified_files, file_whitelist) if not list(commit_modified_files.keys()): print('No Java files to check') return [], [] (tmp_dir, tmp_file_map) = _GetTempFilesForCommit( list(commit_modified_files.keys()), commit) java_files = list(tmp_file_map.keys()) stdout = _ExecuteCheckstyle(java_files, classpath, config_xml) # Remove all the temporary files. shutil.rmtree(tmp_dir) (errors, warnings) = _ParseAndFilterOutput(stdout, commit, commit_modified_files, tmp_file_map) _PrintErrorsAndWarnings(errors, warnings) return errors, warnings def _WarnIfUntrackedFiles(out=sys.stdout): """Prints a warning and a list of untracked files if needed.""" root = git.repository_root() untracked_files = git.modified_files(root, False) untracked_files = {f for f in untracked_files if f.endswith('.java')} if untracked_files: out.write(ERROR_UNTRACKED) for untracked_file in untracked_files: out.write(untracked_file + '\n') out.write('\n') def _PrintErrorsAndWarnings(errors, warnings): """Prints given errors and warnings.""" if errors: print('ERRORS:\n' + '\n'.join(errors)) if warnings: print('WARNINGS:\n' + '\n'.join(warnings)) def _ExecuteCheckstyle(java_files, classpath, config_xml): """Runs Checkstyle to check give Java files for style errors. Args: java_files: A list of Java files that needs to be checked. classpath: The colon-delimited list of JARs in the classpath. config_xml: Path of the checkstyle XML configuration file. Returns: Checkstyle output in XML format. """ # Run checkstyle checkstyle_env = os.environ.copy() checkstyle_env['JAVA_CMD'] = 'java' try: check = subprocess.Popen(['java', '-cp', classpath, 'com.puppycrawl.tools.checkstyle.Main', '-c', config_xml, '-f', 'xml'] + java_files, stdout=subprocess.PIPE, env=checkstyle_env, universal_newlines=True) stdout, _ = check.communicate() stdout_lines = stdout.splitlines() # A work-around for Checkstyle printing error count to stdio. if len(stdout_lines) < 2: stdout = stdout_lines[0] elif len(stdout_lines) >= 2 and '' in stdout_lines[-2]: stdout = '\n'.join(stdout_lines[:-1]) return stdout except OSError as e: if e.errno == errno.ENOENT: print('Error running Checkstyle!') sys.exit(1) def _ParseAndFilterOutput(stdout, sha=None, commit_modified_files=None, tmp_file_map=None): result_errors = [] result_warnings = [] root = xml.dom.minidom.parseString(stdout) for file_element in root.getElementsByTagName('file'): file_name = file_element.attributes['name'].value if tmp_file_map: file_name = tmp_file_map[file_name] modified_lines = None if commit_modified_files: modified_lines = git.modified_lines(file_name, commit_modified_files[file_name], sha) test_class = any(substring in file_name for substring in SUBPATH_FOR_TEST_FILES) test_data_class = any(substring in file_name for substring in SUBPATH_FOR_TEST_DATA_FILES) file_name = os.path.relpath(file_name) errors = file_element.getElementsByTagName('error') for error in errors: line = int(error.attributes['line'].value) rule = error.attributes['source'].value if _ShouldSkip(commit_modified_files, modified_lines, line, rule, test_class, test_data_class): continue column = '' if error.hasAttribute('column'): column = '%s:' % error.attributes['column'].value message = error.attributes['message'].value project = '' if os.environ.get('REPO_PROJECT'): project = '[' + os.environ.get('REPO_PROJECT') + '] ' result = ' %s%s:%s:%s %s' % (project, file_name, line, column, message) severity = error.attributes['severity'].value if severity == 'error': result_errors.append(result) elif severity == 'warning': result_warnings.append(result) return result_errors, result_warnings def _ShouldSkip(commit_check, modified_lines, line, rule, test_class=False, test_data_class=False): """Returns whether an error on a given line should be skipped. Args: commit_check: Whether Checkstyle is being run on a specific commit. modified_lines: A list of lines that has been modified. line: The line that has a rule violation. rule: The type of rule that a given line is violating. test_class: Whether the file being checked is a test class. test_data_class: Whether the file being check is a class used as test data. Returns: A boolean whether a given line should be skipped in the reporting. """ # None modified_lines means checked file is new and nothing should be skipped. if test_data_class: return True if test_class and rule in SKIPPED_RULES_FOR_TEST_FILES: return True if not commit_check: return False if modified_lines is None: return False return line not in modified_lines and rule not in FORCED_RULES def _GetModifiedFiles(commit, explicit_commit=False, out=sys.stdout): root = git.repository_root() pending_files = git.modified_files(root, True) if pending_files and not explicit_commit: out.write(ERROR_UNCOMMITTED) sys.exit(1) modified_files = git.modified_files(root, True, commit) modified_files = {f: modified_files[f] for f in modified_files if f.endswith('.java')} return modified_files def _FilterFiles(files, file_whitelist): if not file_whitelist: return files return {f: files[f] for f in files for whitelist in file_whitelist if whitelist in f} def _GetTempFilesForCommit(file_names, commit): """Creates a temporary snapshot of the files in at a commit. Retrieves the state of every file in file_names at a given commit and writes them all out to a temporary directory. Args: file_names: A list of files that need to be retrieved. commit: A full 40 character SHA-1 of a commit. Returns: A tuple of temprorary directory name and a directionary of temp_file_name: filename. For example: ('/tmp/random/', {'/tmp/random/blarg.java': 'real/path/to/file.java' } """ tmp_dir_name = tempfile.mkdtemp() tmp_file_names = {} for file_name in file_names: rel_path = os.path.relpath(file_name) content = subprocess.check_output( ['git', 'show', commit + ':' + rel_path]) tmp_file_name = os.path.join(tmp_dir_name, rel_path) # create directory for the file if it doesn't exist if not os.path.exists(os.path.dirname(tmp_file_name)): os.makedirs(os.path.dirname(tmp_file_name)) tmp_file = open(tmp_file_name, 'wb') tmp_file.write(content) tmp_file.close() tmp_file_names[tmp_file_name] = file_name return tmp_dir_name, tmp_file_names def main(args=None): """Runs Checkstyle checks on a given set of java files or a commit. It will run Checkstyle on the list of java files first, if unspecified, then the check will be run on a specified commit SHA-1 and if that is None it will fallback to check the latest commit of the currently checked out branch. """ parser = argparse.ArgumentParser() parser.add_argument('--file', '-f', nargs='+') parser.add_argument('--sha', '-s') parser.add_argument('--config_xml', '-c') parser.add_argument('--file_whitelist', '-fw', nargs='+') parser.add_argument('--add_classpath', '-p') args = parser.parse_args() config_xml = args.config_xml or CHECKSTYLE_STYLE if not os.path.exists(config_xml): print('Java checkstyle configuration file is missing') sys.exit(1) classpath = CHECKSTYLE_JAR if args.add_classpath: classpath = args.add_classpath + ':' + classpath if args.file: # Files to check were specified via command line. (errors, warnings) = RunCheckstyleOnFiles(args.file, classpath, config_xml) else: (errors, warnings) = RunCheckstyleOnACommit(args.sha, classpath, config_xml, args.file_whitelist) if errors or warnings: sys.exit(1) print('SUCCESS! NO ISSUES FOUND') sys.exit(0) if __name__ == '__main__': main()