#!/usr/bin/env python3
#
# Copyright 2018 the V8 project authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

# for py2/py3 compatibility
from __future__ import print_function

import json
import multiprocessing
import optparse
import os
import re
import subprocess
import sys

CLANG_TIDY_WARNING = re.compile(r'(\/.*?)\ .*\[(.*)\]$')
CLANG_TIDY_CMDLINE_OUT = re.compile(r'^clang-tidy.*\ .*|^\./\.\*')
FILE_REGEXS = ['../src/*', '../test/*']
HEADER_REGEX = ['\.\.\/src\/.*|\.\.\/include\/.*|\.\.\/test\/.*']

THREADS = multiprocessing.cpu_count()


class ClangTidyWarning(object):
  """
  Wraps up a clang-tidy warning to present aggregated information.
  """

  def __init__(self, warning_type):
    self.warning_type = warning_type
    self.occurrences = set()

  def add_occurrence(self, file_path):
    self.occurrences.add(file_path.lstrip())

  def __hash__(self):
    return hash(self.warning_type)

  def to_string(self, file_loc):
    s = '[%s] #%d\n' % (self.warning_type, len(self.occurrences))
    if file_loc:
      s += ' ' + '\n  '.join(self.occurrences)
      s += '\n'
    return s

  def __str__(self):
    return self.to_string(False)

  def __lt__(self, other):
    return len(self.occurrences) < len(other.occurrences)


def GenerateCompileCommands(build_folder):
  """
  Generate a compilation database.

  Currently clang-tidy-4 does not understand all flags that are passed
  by the build system, therefore, we remove them from the generated file.
  """
  ninja_ps = subprocess.Popen(
    ['ninja', '-t', 'compdb', 'cxx', 'cc'],
    stdout=subprocess.PIPE,
    cwd=build_folder)

  out_filepath = os.path.join(build_folder, 'compile_commands.json')
  with open(out_filepath, 'w') as cc_file:
    while True:
        line = ninja_ps.stdout.readline()

        if line == '':
            break

        line = line.replace('-fcomplete-member-pointers', '')
        line = line.replace('-Wno-enum-compare-switch', '')
        line = line.replace('-Wno-ignored-pragma-optimize', '')
        line = line.replace('-Wno-null-pointer-arithmetic', '')
        line = line.replace('-Wno-unused-lambda-capture', '')
        line = line.replace('-Wno-defaulted-function-deleted', '')
        cc_file.write(line)


def skip_line(line):
  """
  Check if a clang-tidy output line should be skipped.
  """
  return bool(CLANG_TIDY_CMDLINE_OUT.search(line))


def ClangTidyRunFull(build_folder, skip_output_filter, checks, auto_fix):
  """
  Run clang-tidy on the full codebase and print warnings.
  """
  extra_args = []
  if auto_fix:
    extra_args.append('-fix')

  if checks is not None:
    extra_args.append('-checks')
    extra_args.append('-*, ' + checks)

  with open(os.devnull, 'w') as DEVNULL:
    ct_process = subprocess.Popen(
      ['run-clang-tidy', '-j' + str(THREADS), '-p', '.']
       + ['-header-filter'] + HEADER_REGEX + extra_args
       + FILE_REGEXS,
      cwd=build_folder,
      stdout=subprocess.PIPE,
      stderr=DEVNULL)
  removing_check_header = False
  empty_lines = 0

  while True:
    line = ct_process.stdout.readline()
    if line == '':
      break

    # Skip all lines after Enbale checks and before two newlines,
    # i.e., skip clang-tidy check list.
    if line.startswith('Enabled checks'):
      removing_check_header = True
    if removing_check_header and not skip_output_filter:
      if line == '\n':
        empty_lines += 1
      if empty_lines == 2:
        removing_check_header = False
      continue

    # Different lines get removed to ease output reading.
    if not skip_output_filter and skip_line(line):
      continue

    # Print line, because no filter was matched.
    if line != '\n':
        sys.stdout.write(line)


def ClangTidyRunAggregate(build_folder, print_files):
  """
  Run clang-tidy on the full codebase and aggregate warnings into categories.
  """
  with open(os.devnull, 'w') as DEVNULL:
    ct_process = subprocess.Popen(
      ['run-clang-tidy', '-j' + str(THREADS), '-p', '.'] +
        ['-header-filter'] + HEADER_REGEX +
        FILE_REGEXS,
      cwd=build_folder,
      stdout=subprocess.PIPE,
      stderr=DEVNULL)
  warnings = dict()
  while True:
    line = ct_process.stdout.readline()
    if line == '':
      break

    res = CLANG_TIDY_WARNING.search(line)
    if res is not None:
      warnings.setdefault(
          res.group(2),
          ClangTidyWarning(res.group(2))).add_occurrence(res.group(1))

  for warning in sorted(warnings.values(), reverse=True):
    sys.stdout.write(warning.to_string(print_files))


def ClangTidyRunDiff(build_folder, diff_branch, auto_fix):
  """
  Run clang-tidy on the diff between current and the diff_branch.
  """
  if diff_branch is None:
    diff_branch = subprocess.check_output(['git', 'merge-base',
                                           'HEAD', 'origin/master']).strip()

  git_ps = subprocess.Popen(
    ['git', 'diff', '-U0', diff_branch], stdout=subprocess.PIPE)

  extra_args = []
  if auto_fix:
    extra_args.append('-fix')

  with open(os.devnull, 'w') as DEVNULL:
    """
    The script `clang-tidy-diff` does not provide support to add header-
    filters. To still analyze headers we use the build path option `-path` to
    inject our header-filter option. This works because the script just adds
    the passed path string to the commandline of clang-tidy.
    """
    modified_build_folder = build_folder
    modified_build_folder += ' -header-filter='
    modified_build_folder += '\'' + ''.join(HEADER_REGEX) + '\''

    ct_ps = subprocess.Popen(
      ['clang-tidy-diff.py', '-path', modified_build_folder, '-p1'] +
        extra_args,
      stdin=git_ps.stdout,
      stdout=subprocess.PIPE,
      stderr=DEVNULL)
  git_ps.wait()
  while True:
    line = ct_ps.stdout.readline()
    if line == '':
      break

    if skip_line(line):
      continue

    sys.stdout.write(line)


def rm_prefix(string, prefix):
  """
  Removes prefix from a string until the new string
  no longer starts with the prefix.
  """
  while string.startswith(prefix):
    string = string[len(prefix):]
  return string


def ClangTidyRunSingleFile(build_folder, filename_to_check, auto_fix,
                           line_ranges=[]):
  """
  Run clang-tidy on a single file.
  """
  files_with_relative_path = []

  compdb_filepath = os.path.join(build_folder, 'compile_commands.json')
  with open(compdb_filepath) as raw_json_file:
    compdb = json.load(raw_json_file)

  for db_entry in compdb:
    if db_entry['file'].endswith(filename_to_check):
      files_with_relative_path.append(db_entry['file'])

  with open(os.devnull, 'w') as DEVNULL:
    for file_with_relative_path in files_with_relative_path:
      line_filter = None
      if len(line_ranges) != 0:
        line_filter = '['
        line_filter += '{ \"lines\":[' + ', '.join(line_ranges)
        line_filter += '], \"name\":\"'
        line_filter += rm_prefix(file_with_relative_path,
                                 '../') + '\"}'
        line_filter += ']'

      extra_args = ['-line-filter=' + line_filter] if line_filter else []

      if auto_fix:
        extra_args.append('-fix')

      subprocess.call(['clang-tidy', '-p', '.'] +
                      extra_args +
                      [file_with_relative_path],
                      cwd=build_folder,
                      stderr=DEVNULL)


def CheckClangTidy():
  """
  Checks if a clang-tidy binary exists.
  """
  with open(os.devnull, 'w') as DEVNULL:
    return subprocess.call(['which', 'clang-tidy'], stdout=DEVNULL) == 0


def CheckCompDB(build_folder):
  """
  Checks if a compilation database exists in the build_folder.
  """
  return os.path.isfile(os.path.join(build_folder, 'compile_commands.json'))


def DetectBuildFolder():
    """
    Tries to auto detect the last used build folder in out/
    """
    outdirs_folder = 'out/'
    last_used = None
    last_timestamp = -1
    for outdir in [outdirs_folder + folder_name
                   for folder_name in os.listdir(outdirs_folder)
                   if os.path.isdir(outdirs_folder + folder_name)]:
        outdir_modified_timestamp = os.path.getmtime(outdir)
        if  outdir_modified_timestamp > last_timestamp:
            last_timestamp = outdir_modified_timestamp
            last_used = outdir

    return last_used


def GetOptions():
  """
  Generate the option parser for this script.
  """
  result = optparse.OptionParser()
  result.add_option(
    '-b',
    '--build-folder',
    help='Set V8 build folder',
    dest='build_folder',
    default=None)
  result.add_option(
    '-j',
    help='Set the amount of threads that should be used',
    dest='threads',
    default=None)
  result.add_option(
    '--gen-compdb',
    help='Generate a compilation database for clang-tidy',
    default=False,
    action='store_true')
  result.add_option(
    '--no-output-filter',
    help='Done use any output filterning',
    default=False,
    action='store_true')
  result.add_option(
    '--fix',
    help='Fix auto fixable issues',
    default=False,
    dest='auto_fix',
    action='store_true'
  )

  # Full clang-tidy.
  full_run_g = optparse.OptionGroup(result, 'Clang-tidy full', '')
  full_run_g.add_option(
    '--full',
    help='Run clang-tidy on the whole codebase',
    default=False,
    action='store_true')
  full_run_g.add_option('--checks',
                        help='Clang-tidy checks to use.',
                        default=None)
  result.add_option_group(full_run_g)

  # Aggregate clang-tidy.
  agg_run_g = optparse.OptionGroup(result, 'Clang-tidy aggregate', '')
  agg_run_g.add_option('--aggregate', help='Run clang-tidy on the whole '\
             'codebase and aggregate the warnings',
             default=False, action='store_true')
  agg_run_g.add_option('--show-loc', help='Show file locations when running '\
             'in aggregate mode', default=False,
             action='store_true')
  result.add_option_group(agg_run_g)

  # Diff clang-tidy.
  diff_run_g = optparse.OptionGroup(result, 'Clang-tidy diff', '')
  diff_run_g.add_option('--branch', help='Run clang-tidy on the diff '\
             'between HEAD and the merge-base between HEAD '\
             'and DIFF_BRANCH (origin/master by default).',
             default=None, dest='diff_branch')
  result.add_option_group(diff_run_g)

  # Single clang-tidy.
  single_run_g = optparse.OptionGroup(result, 'Clang-tidy single', '')
  single_run_g.add_option(
    '--single', help='', default=False, action='store_true')
  single_run_g.add_option(
    '--file', help='File name to check', default=None, dest='file_name')
  single_run_g.add_option('--lines', help='Limit checks to a line range. '\
              'For example: --lines="[2,4], [5,6]"',
              default=[], dest='line_ranges')

  result.add_option_group(single_run_g)
  return result


def main():
  parser = GetOptions()
  (options, _) = parser.parse_args()

  if options.threads is not None:
    global THREADS
    THREADS = options.threads

  if options.build_folder is None:
    options.build_folder = DetectBuildFolder()

  if not CheckClangTidy():
    print('Could not find clang-tidy')
  elif options.build_folder is None or not os.path.isdir(options.build_folder):
    print('Please provide a build folder with -b')
  elif options.gen_compdb:
    GenerateCompileCommands(options.build_folder)
  elif not CheckCompDB(options.build_folder):
    print('Could not find compilation database, ' \
      'please generate it with --gen-compdb')
  else:
    print('Using build folder:', options.build_folder)
    if options.full:
      print('Running clang-tidy - full')
      ClangTidyRunFull(options.build_folder,
                       options.no_output_filter,
                       options.checks,
                       options.auto_fix)
    elif options.aggregate:
      print('Running clang-tidy - aggregating warnings')
      if options.auto_fix:
        print('Auto fix not working in aggregate mode, running without.')
      ClangTidyRunAggregate(options.build_folder, options.show_loc)
    elif options.single:
      print('Running clang-tidy - single on ' + options.file_name)
      if options.file_name is not None:
        line_ranges = []
        for match in re.findall(r'(\[.*?\])', options.line_ranges):
          if match is not []:
            line_ranges.append(match)
        ClangTidyRunSingleFile(options.build_folder,
                               options.file_name,
                               options.auto_fix,
                               line_ranges)
      else:
        print('Filename provided, please specify a filename with --file')
    else:
      print('Running clang-tidy')
      ClangTidyRunDiff(options.build_folder,
                       options.diff_branch,
                       options.auto_fix)


if __name__ == '__main__':
  main()
