#!/usr/bin/env python3
# Copyright 2012 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Makes sure that files include headers from allowed directories.

Checks DEPS files in the source tree for rules, and applies those rules to
"#include" and "import" directives in the .cpp, .java, and .proto source files.
Any source file including something not permitted by the DEPS files will fail.

See README.md for a detailed description of the DEPS format.
"""



import os
import optparse
import re
import sys

import proto_checker
import cpp_checker
import java_checker
import results

from builddeps import DepsBuilder
from rules import Rule, Rules


def _IsTestFile(filename):
  """Does a rudimentary check to try to skip test files; this could be
  improved but is good enough for now.
  """
  return re.match(r'(test|mock|dummy)_.*|.*_[a-z]*test\.(cc|mm|java)', filename)


class DepsChecker(DepsBuilder):
  """Parses include_rules from DEPS files and verifies files in the
  source tree against them.
  """

  def __init__(self,
               base_directory=None,
               extra_repos=[],
               verbose=False,
               being_tested=False,
               ignore_temp_rules=False,
               skip_tests=False,
               resolve_dotdot=True):
    """Creates a new DepsChecker.

    Args:
      base_directory: OS-compatible path to root of checkout, e.g. C:\chr\src.
      verbose: Set to true for debug output.
      being_tested: Set to true to ignore the DEPS file at
                    buildtools/checkdeps/DEPS.
      ignore_temp_rules: Ignore rules that start with Rule.TEMP_ALLOW ("!").
    """
    DepsBuilder.__init__(
        self, base_directory, extra_repos, verbose, being_tested,
        ignore_temp_rules)

    self._skip_tests = skip_tests
    self._resolve_dotdot = resolve_dotdot
    self.results_formatter = results.NormalResultsFormatter(verbose)

  def Report(self):
    """Prints a report of results, and returns an exit code for the process."""
    if self.results_formatter.GetResults():
      self.results_formatter.PrintResults()
      return 1
    print('\nSUCCESS\n')
    return 0

  def CheckDirectory(self, start_dir):
    """Checks all relevant source files in the specified directory and
    its subdirectories for compliance with DEPS rules throughout the
    tree (starting at |self.base_directory|).  |start_dir| must be a
    subdirectory of |self.base_directory|.

    On completion, self.results_formatter has the results of
    processing, and calling Report() will print a report of results.
    """
    java = java_checker.JavaChecker(self.base_directory, self.verbose)
    cpp = cpp_checker.CppChecker(
        self.verbose, self._resolve_dotdot, self.base_directory)
    proto = proto_checker.ProtoChecker(
        self.verbose, self._resolve_dotdot, self.base_directory)
    checkers = dict(
        (extension, checker)
        for checker in [java, cpp, proto] for extension in checker.EXTENSIONS)

    for rules, file_paths in self.GetAllRulesAndFiles(start_dir):
      for full_name in file_paths:
        if self._skip_tests and _IsTestFile(os.path.basename(full_name)):
          continue
        file_extension = os.path.splitext(full_name)[1]
        if not file_extension in checkers:
          continue
        checker = checkers[file_extension]
        file_status = checker.CheckFile(rules, full_name)
        if file_status.HasViolations():
          self.results_formatter.AddError(file_status)

  def CheckIncludesAndImports(self, added_lines, checker):
    """Check new import/#include statements added in the change
    being presubmit checked.

    Args:
      added_lines: ((file_path, (changed_line, changed_line, ...), ...)
      checker: CppChecker/JavaChecker/ProtoChecker checker instance

    Return:
      A list of tuples, (bad_file_path, rule_type, rule_description)
      where rule_type is one of Rule.DISALLOW or Rule.TEMP_ALLOW and
      rule_description is human-readable. Empty if no problems.
    """
    problems = []
    for file_path, changed_lines in added_lines:
      if not checker.ShouldCheck(file_path):
        continue
      rules_for_file = self.GetDirectoryRules(os.path.dirname(file_path))
      if not rules_for_file:
        continue
      for line in changed_lines:
        is_include, violation = checker.CheckLine(
            rules_for_file, line, file_path, True)
        if not violation:
          continue
        rule_type = violation.violated_rule.allow
        if rule_type == Rule.ALLOW:
          continue
        violation_text = results.NormalResultsFormatter.FormatViolation(
            violation, self.verbose)
        problems.append((file_path, rule_type, violation_text))
    return problems

  def CheckAddedCppIncludes(self, added_includes):
    """This is used from PRESUBMIT.py to check new #include statements added in
    the change being presubmit checked.

    Args:
      added_includes: ((file_path, (include_line, include_line, ...), ...)

    Return:
      A list of tuples, (bad_file_path, rule_type, rule_description)
      where rule_type is one of Rule.DISALLOW or Rule.TEMP_ALLOW and
      rule_description is human-readable. Empty if no problems.
    """
    return self.CheckIncludesAndImports(
        added_includes, cpp_checker.CppChecker(self.verbose))

  def CheckAddedJavaImports(self, added_imports,
                            allow_multiple_definitions=None):
    """This is used from PRESUBMIT.py to check new import statements added in
    the change being presubmit checked.

    Args:
      added_imports: ((file_path, (import_line, import_line, ...), ...)
      allow_multiple_definitions: [file_name, file_name, ...]. List of java
                                  file names allowing multiple definitions in
                                  presubmit check.

    Return:
      A list of tuples, (bad_file_path, rule_type, rule_description)
      where rule_type is one of Rule.DISALLOW or Rule.TEMP_ALLOW and
      rule_description is human-readable. Empty if no problems.
    """
    return self.CheckIncludesAndImports(
        added_imports,
        java_checker.JavaChecker(self.base_directory, self.verbose,
                                 added_imports, allow_multiple_definitions))

  def CheckAddedProtoImports(self, added_imports):
    """This is used from PRESUBMIT.py to check new #import statements added in
    the change being presubmit checked.

    Args:
      added_imports : ((file_path, (import_line, import_line, ...), ...)

    Return:
      A list of tuples, (bad_file_path, rule_type, rule_description)
      where rule_type is one of Rule.DISALLOW or Rule.TEMP_ALLOW and
      rule_description is human-readable. Empty if no problems.
    """
    return self.CheckIncludesAndImports(
        added_imports, proto_checker.ProtoChecker(
            verbose=self.verbose, root_dir=self.base_directory))

def PrintUsage():
  print("""Usage: python checkdeps.py [--root <root>] [tocheck]

  --root ROOT Specifies the repository root. This defaults to "../../.."
              relative to the script file. This will be correct given the
              normal location of the script in "<root>/buildtools/checkdeps".

  --(others)  There are a few lesser-used options; run with --help to show them.

  tocheck  Specifies the directory, relative to root, to check. This defaults
           to "." so it checks everything.

Examples:
  python checkdeps.py
  python checkdeps.py --root c:\\source chrome""")


def main():
  option_parser = optparse.OptionParser()
  option_parser.add_option(
      '', '--root',
      default='', dest='base_directory',
      help='Specifies the repository root. This defaults '
           'to "../../.." relative to the script file, which '
           'will normally be the repository root.')
  option_parser.add_option(
      '', '--extra-repos',
      action='append', dest='extra_repos', default=[],
      help='Specifies extra repositories relative to root repository.')
  option_parser.add_option(
      '', '--ignore-temp-rules',
      action='store_true', dest='ignore_temp_rules', default=False,
      help='Ignore !-prefixed (temporary) rules.')
  option_parser.add_option(
      '', '--generate-temp-rules',
      action='store_true', dest='generate_temp_rules', default=False,
      help='Print rules to temporarily allow files that fail '
           'dependency checking.')
  option_parser.add_option(
      '', '--count-violations',
      action='store_true', dest='count_violations', default=False,
      help='Count #includes in violation of intended rules.')
  option_parser.add_option(
      '', '--skip-tests',
      action='store_true', dest='skip_tests', default=False,
      help='Skip checking test files (best effort).')
  option_parser.add_option(
      '-v', '--verbose',
      action='store_true', default=False,
      help='Print debug logging')
  option_parser.add_option(
      '', '--json',
      help='Path to JSON output file')
  option_parser.add_option(
      '', '--no-resolve-dotdot',
      action='store_false', dest='resolve_dotdot', default=True,
      help='resolve leading ../ in include directive paths relative '
           'to the file perfoming the inclusion.')

  options, args = option_parser.parse_args()

  deps_checker = DepsChecker(options.base_directory,
                             extra_repos=options.extra_repos,
                             verbose=options.verbose,
                             ignore_temp_rules=options.ignore_temp_rules,
                             skip_tests=options.skip_tests,
                             resolve_dotdot=options.resolve_dotdot)
  base_directory = deps_checker.base_directory  # Default if needed, normalized

  # Figure out which directory we have to check.
  start_dir = base_directory
  if len(args) == 1:
    # Directory specified. Start here. It's supposed to be relative to the
    # base directory.
    start_dir = os.path.abspath(os.path.join(base_directory, args[0]))
  elif len(args) >= 2 or (options.generate_temp_rules and
                          options.count_violations):
    # More than one argument, or incompatible flags, we don't handle this.
    PrintUsage()
    return 1

  if not start_dir.startswith(deps_checker.base_directory):
    print('Directory to check must be a subdirectory of the base directory,')
    print('but %s is not a subdirectory of %s' % (start_dir, base_directory))
    return 1

  print('Using base directory:', base_directory)
  print('Checking:', start_dir)

  if options.generate_temp_rules:
    deps_checker.results_formatter = results.TemporaryRulesFormatter()
  elif options.count_violations:
    deps_checker.results_formatter = results.CountViolationsFormatter()

  if options.json:
    deps_checker.results_formatter = results.JSONResultsFormatter(
        options.json, deps_checker.results_formatter)

  deps_checker.CheckDirectory(start_dir)
  return deps_checker.Report()


if '__main__' == __name__:
  sys.exit(main())
