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