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