xref: /aosp_15_r20/prebuilts/checkstyle/checkstyle.py (revision 387726c4b5c67c6b48512fa4a28a3b8997d21b0d)
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