1#!/usr/bin/env python3 2 3# 4# Copyright 2015, The Android Open Source Project 5# 6# Licensed under the Apache License, Version 2.0 (the "License"); 7# you may not use this file except in compliance with the License. 8# You may obtain a copy of the License at 9# 10# http://www.apache.org/licenses/LICENSE-2.0 11# 12# Unless required by applicable law or agreed to in writing, software 13# distributed under the License is distributed on an "AS IS" BASIS, 14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15# See the License for the specific language governing permissions and 16# limitations under the License. 17# 18 19"""Script that is used by developers to run style checks on Java files.""" 20 21from __future__ import print_function 22 23import argparse 24import errno 25import os 26import shutil 27import subprocess 28import sys 29import tempfile 30import xml.dom.minidom 31import gitlint.git as git 32 33 34def _FindFoldersContaining(root, wanted): 35 """Recursively finds directories that have a file with the given name. 36 37 Args: 38 root: Root folder to start the search from. 39 wanted: The filename that we are looking for. 40 41 Returns: 42 List of folders that has a file with the given name 43 """ 44 45 if not root: 46 return [] 47 if os.path.islink(root): 48 return [] 49 result = [] 50 for file_name in os.listdir(root): 51 file_path = os.path.join(root, file_name) 52 if os.path.isdir(file_path): 53 sub_result = _FindFoldersContaining(file_path, wanted) 54 result.extend(sub_result) 55 else: 56 if file_name == wanted: 57 result.append(root) 58 return result 59 60MAIN_DIRECTORY = os.path.normpath(os.path.dirname(__file__)) 61CHECKSTYLE_JAR = os.path.join(MAIN_DIRECTORY, 'checkstyle.jar') 62CHECKSTYLE_STYLE = os.path.join(MAIN_DIRECTORY, 'android-style.xml') 63FORCED_RULES = ['com.puppycrawl.tools.checkstyle.checks.imports.ImportOrderCheck', 64 'com.puppycrawl.tools.checkstyle.checks.imports.UnusedImportsCheck'] 65SKIPPED_RULES_FOR_TEST_FILES = ['com.puppycrawl.tools.checkstyle.checks.javadoc.JavadocTypeCheck', 66 'com.puppycrawl.tools.checkstyle.checks.javadoc.JavadocMethodCheck'] 67SUBPATH_FOR_TEST_FILES = ['/tests/', '/test/', '/androidTest/', '/perftests/', '/gts-tests/', 68 '/hostsidetests/', '/jvmTest/', '/robotests/', '/robolectric/'] 69SUBPATH_FOR_TEST_DATA_FILES = _FindFoldersContaining(git.repository_root(), 70 'IGNORE_CHECKSTYLE') 71ERROR_UNCOMMITTED = 'You need to commit all modified files before running Checkstyle\n' 72ERROR_UNTRACKED = 'You have untracked java files that are not being checked:\n' 73 74 75def RunCheckstyleOnFiles(java_files, classpath=CHECKSTYLE_JAR, config_xml=CHECKSTYLE_STYLE): 76 """Runs Checkstyle checks on a given set of java_files. 77 78 Args: 79 java_files: A list of files to check. 80 classpath: The colon-delimited list of JARs in the classpath. 81 config_xml: Path of the checkstyle XML configuration file. 82 83 Returns: 84 A tuple of errors and warnings. 85 """ 86 print('Running Checkstyle on inputted files') 87 java_files = list(map(os.path.abspath, java_files)) 88 stdout = _ExecuteCheckstyle(java_files, classpath, config_xml) 89 (errors, warnings) = _ParseAndFilterOutput(stdout) 90 _PrintErrorsAndWarnings(errors, warnings) 91 return errors, warnings 92 93 94def RunCheckstyleOnACommit(commit, 95 classpath=CHECKSTYLE_JAR, 96 config_xml=CHECKSTYLE_STYLE, 97 file_whitelist=None): 98 """Runs Checkstyle checks on a given commit. 99 100 It will run Checkstyle on the changed Java files in a specified commit SHA-1 101 and if that is None it will fallback to check the latest commit of the 102 currently checked out branch. 103 104 Args: 105 commit: A full 40 character SHA-1 of a commit to check. 106 classpath: The colon-delimited list of JARs in the classpath. 107 config_xml: Path of the checkstyle XML configuration file. 108 file_whitelist: A list of whitelisted file paths that should be checked. 109 110 Returns: 111 A tuple of errors and warnings. 112 """ 113 if not git.repository_root(): 114 print('FAILURE: not inside a git repository') 115 sys.exit(1) 116 explicit_commit = commit is not None 117 if not explicit_commit: 118 _WarnIfUntrackedFiles() 119 commit = git.last_commit() 120 print('Running Checkstyle on %s commit' % commit) 121 commit_modified_files = _GetModifiedFiles(commit, explicit_commit) 122 commit_modified_files = _FilterFiles(commit_modified_files, file_whitelist) 123 if not list(commit_modified_files.keys()): 124 print('No Java files to check') 125 return [], [] 126 127 (tmp_dir, tmp_file_map) = _GetTempFilesForCommit( 128 list(commit_modified_files.keys()), commit) 129 130 java_files = list(tmp_file_map.keys()) 131 stdout = _ExecuteCheckstyle(java_files, classpath, config_xml) 132 133 # Remove all the temporary files. 134 shutil.rmtree(tmp_dir) 135 136 (errors, warnings) = _ParseAndFilterOutput(stdout, 137 commit, 138 commit_modified_files, 139 tmp_file_map) 140 _PrintErrorsAndWarnings(errors, warnings) 141 return errors, warnings 142 143 144def _WarnIfUntrackedFiles(out=sys.stdout): 145 """Prints a warning and a list of untracked files if needed.""" 146 root = git.repository_root() 147 untracked_files = git.modified_files(root, False) 148 untracked_files = {f for f in untracked_files if f.endswith('.java')} 149 if untracked_files: 150 out.write(ERROR_UNTRACKED) 151 for untracked_file in untracked_files: 152 out.write(untracked_file + '\n') 153 out.write('\n') 154 155 156def _PrintErrorsAndWarnings(errors, warnings): 157 """Prints given errors and warnings.""" 158 if errors: 159 print('ERRORS:\n' + '\n'.join(errors)) 160 if warnings: 161 print('WARNINGS:\n' + '\n'.join(warnings)) 162 163def _CheckForJava(): 164 try: 165 java_env = os.environ.copy() 166 java_env['JAVA_CMD'] = 'java' 167 check = subprocess.Popen(['java', '--help'], 168 stdout=subprocess.PIPE, env=java_env, 169 universal_newlines=True) 170 stdout, _ = check.communicate() 171 stdout_lines = stdout.splitlines() 172 except OSError as e: 173 if e.errno == errno.ENOENT: 174 print('Error: Could not find `java` on path!') 175 sys.exit(1) 176 177def _ExecuteCheckstyle(java_files, classpath, config_xml): 178 """Runs Checkstyle to check give Java files for style errors. 179 180 Args: 181 java_files: A list of Java files that needs to be checked. 182 classpath: The colon-delimited list of JARs in the classpath. 183 config_xml: Path of the checkstyle XML configuration file. 184 185 Returns: 186 Checkstyle output in XML format. 187 """ 188 # Run checkstyle 189 checkstyle_env = os.environ.copy() 190 checkstyle_env['JAVA_CMD'] = 'java' 191 192 try: 193 check = subprocess.Popen(['java', 194 '-Dcheckstyle.enableExternalDtdLoad=true', 195 '-cp', classpath, 196 'com.puppycrawl.tools.checkstyle.Main', '-c', 197 config_xml, '-f', 'xml'] + java_files, 198 stdout=subprocess.PIPE, env=checkstyle_env, 199 universal_newlines=True) 200 stdout, _ = check.communicate() 201 stdout_lines = stdout.splitlines() 202 # A work-around for Checkstyle printing error count to stdio. 203 if len(stdout_lines) < 2: 204 stdout = stdout_lines[0] 205 elif len(stdout_lines) >= 2 and '</checkstyle>' in stdout_lines[-2]: 206 stdout = '\n'.join(stdout_lines[:-1]) 207 return stdout 208 except OSError as e: 209 if e.errno == errno.ENOENT: 210 _CheckForJava() 211 print('Error running Checkstyle!') 212 sys.exit(1) 213 214 215def _ParseAndFilterOutput(stdout, 216 sha=None, 217 commit_modified_files=None, 218 tmp_file_map=None): 219 result_errors = [] 220 result_warnings = [] 221 root = xml.dom.minidom.parseString(stdout) 222 for file_element in root.getElementsByTagName('file'): 223 file_name = file_element.attributes['name'].value 224 if tmp_file_map: 225 file_name = tmp_file_map[file_name] 226 modified_lines = None 227 if commit_modified_files: 228 modified_lines = git.modified_lines(file_name, 229 commit_modified_files[file_name], 230 sha) 231 test_class = any(substring in file_name for substring 232 in SUBPATH_FOR_TEST_FILES) 233 test_data_class = any(substring in file_name for substring 234 in SUBPATH_FOR_TEST_DATA_FILES) 235 file_name = os.path.relpath(file_name) 236 errors = file_element.getElementsByTagName('error') 237 for error in errors: 238 line = int(error.attributes['line'].value) 239 rule = error.attributes['source'].value 240 if _ShouldSkip(commit_modified_files, modified_lines, line, rule, 241 test_class, test_data_class): 242 continue 243 244 column = '' 245 if error.hasAttribute('column'): 246 column = '%s:' % error.attributes['column'].value 247 message = error.attributes['message'].value 248 project = '' 249 if os.environ.get('REPO_PROJECT'): 250 project = '[' + os.environ.get('REPO_PROJECT') + '] ' 251 252 result = ' %s%s:%s:%s %s' % (project, file_name, line, column, message) 253 254 severity = error.attributes['severity'].value 255 if severity == 'error': 256 result_errors.append(result) 257 elif severity == 'warning': 258 result_warnings.append(result) 259 return result_errors, result_warnings 260 261 262def _ShouldSkip(commit_check, modified_lines, line, rule, test_class=False, 263 test_data_class=False): 264 """Returns whether an error on a given line should be skipped. 265 266 Args: 267 commit_check: Whether Checkstyle is being run on a specific commit. 268 modified_lines: A list of lines that has been modified. 269 line: The line that has a rule violation. 270 rule: The type of rule that a given line is violating. 271 test_class: Whether the file being checked is a test class. 272 test_data_class: Whether the file being check is a class used as test data. 273 274 Returns: 275 A boolean whether a given line should be skipped in the reporting. 276 """ 277 # None modified_lines means checked file is new and nothing should be skipped. 278 if test_data_class: 279 return True 280 if test_class and rule in SKIPPED_RULES_FOR_TEST_FILES: 281 return True 282 if not commit_check: 283 return False 284 if modified_lines is None: 285 return False 286 return line not in modified_lines and rule not in FORCED_RULES 287 288 289def _GetModifiedFiles(commit, explicit_commit=False, out=sys.stdout): 290 root = git.repository_root() 291 pending_files = git.modified_files(root, True) 292 if pending_files and not explicit_commit: 293 out.write(ERROR_UNCOMMITTED) 294 sys.exit(1) 295 296 modified_files = git.modified_files(root, True, commit) 297 modified_files = {f: modified_files[f] for f 298 in modified_files if f.endswith('.java')} 299 return modified_files 300 301 302def _FilterFiles(files, file_whitelist): 303 if not file_whitelist: 304 return files 305 return {f: files[f] for f in files 306 for whitelist in file_whitelist if whitelist in f} 307 308 309def _GetTempFilesForCommit(file_names, commit): 310 """Creates a temporary snapshot of the files in at a commit. 311 312 Retrieves the state of every file in file_names at a given commit and writes 313 them all out to a temporary directory. 314 315 Args: 316 file_names: A list of files that need to be retrieved. 317 commit: A full 40 character SHA-1 of a commit. 318 319 Returns: 320 A tuple of temprorary directory name and a directionary of 321 temp_file_name: filename. For example: 322 323 ('/tmp/random/', {'/tmp/random/blarg.java': 'real/path/to/file.java' } 324 """ 325 tmp_dir_name = tempfile.mkdtemp() 326 tmp_file_names = {} 327 for file_name in file_names: 328 rel_path = os.path.relpath(file_name) 329 content = subprocess.check_output( 330 ['git', 'show', commit + ':' + rel_path]) 331 332 tmp_file_name = os.path.join(tmp_dir_name, rel_path) 333 # create directory for the file if it doesn't exist 334 if not os.path.exists(os.path.dirname(tmp_file_name)): 335 os.makedirs(os.path.dirname(tmp_file_name)) 336 337 tmp_file = open(tmp_file_name, 'wb') 338 tmp_file.write(content) 339 tmp_file.close() 340 tmp_file_names[tmp_file_name] = file_name 341 return tmp_dir_name, tmp_file_names 342 343 344def main(args=None): 345 """Runs Checkstyle checks on a given set of java files or a commit. 346 347 It will run Checkstyle on the list of java files first, if unspecified, 348 then the check will be run on a specified commit SHA-1 and if that 349 is None it will fallback to check the latest commit of the currently checked 350 out branch. 351 """ 352 parser = argparse.ArgumentParser() 353 parser.add_argument('--file', '-f', nargs='+') 354 parser.add_argument('--sha', '-s') 355 parser.add_argument('--config_xml', '-c') 356 parser.add_argument('--file_whitelist', '-fw', nargs='+') 357 parser.add_argument('--add_classpath', '-p') 358 args = parser.parse_args() 359 360 config_xml = args.config_xml or CHECKSTYLE_STYLE 361 362 if not os.path.exists(config_xml): 363 print('Java checkstyle configuration file is missing') 364 sys.exit(1) 365 366 classpath = CHECKSTYLE_JAR 367 368 if args.add_classpath: 369 classpath = args.add_classpath + ':' + classpath 370 371 if args.file: 372 # Files to check were specified via command line. 373 (errors, warnings) = RunCheckstyleOnFiles(args.file, classpath, config_xml) 374 else: 375 (errors, warnings) = RunCheckstyleOnACommit(args.sha, classpath, config_xml, 376 args.file_whitelist) 377 378 if errors or warnings: 379 sys.exit(1) 380 381 print('SUCCESS! NO ISSUES FOUND') 382 sys.exit(0) 383 384 385if __name__ == '__main__': 386 main() 387