# Copyright 2018 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# pragma pylint: disable=invalid-name, import-error
"""
This file acts as a git pre-submission checker, and uses the support tooling
from depot_tools to check a variety of style and programming requirements.
"""

from collections import namedtuple
import os
import re
import sys


_REPO_PATH = os.path.dirname(os.path.realpath('__file__'))
_IMPORT_SUBFOLDERS = ['tools', os.path.join('buildtools', 'checkdeps')]

# git-cl upload is not compatible with __init__.py based subfolder imports, so
# we extend the system path instead.
#
# TODO(crbug.com/1502599): Migrate these modules to depot_tools and import from
# there.
sys.path.extend(os.path.join(_REPO_PATH, p) for p in _IMPORT_SUBFOLDERS)


from checkdeps import DepsChecker  # pylint: disable=wrong-import-position
import licenses  # pylint: disable=wrong-import-position

# Opt-in to using Python3 instead of Python2, as part of the ongoing Python2
# deprecation. For more information, see
# https://issuetracker.google.com/173766869.
USE_PYTHON3 = True


def _CheckLicenses(input_api, output_api):
    """Checks third party licenses and returns a list of violations."""
    # NOTE: the licenses check is confused by the fact that we don't actually
    # check ou the libraries in buildtools/third_party, so explicitly exclude
    # that folder. See https://crbug.com/1215335 for more info.
    licenses.PRUNE_PATHS.add(os.path.join('buildtools', 'third_party'))

    if any(s.LocalPath().startswith('third_party')
           for s in input_api.change.AffectedFiles()):
        return [
            output_api.PresubmitError(v)
            for v in licenses.ScanThirdPartyDirs()
        ]
    return []


def _CheckDeps(input_api, output_api):
    """Checks DEPS rules and returns a list of violations."""
    deps_checker = DepsChecker(input_api.PresubmitLocalPath())
    deps_checker.CheckDirectory(input_api.PresubmitLocalPath())
    deps_results = deps_checker.results_formatter.GetResults()
    return [output_api.PresubmitError(v) for v in deps_results]


# Arguments passed to methods by cpplint.
CpplintArgs = namedtuple("CpplintArgs", "filename clean_lines linenum error")

# A defined error to return to cpplint.
Error = namedtuple("Error", "type message")


def _CheckNoRegexMatches(regex, cpplint_args, error, include_cpp_files=True):
    """Checks that there are no matches for a specific regex.

  Args:
    regex: The regex to use for matching.
    cpplint_args: The arguments passed to us by cpplint.
    error: The error to return if we find an issue.
    include_cpp_files: Whether C++ files should be checked.
    """
    if not include_cpp_files and not cpplint_args.filename.endswith('.h'):
        return

    line = cpplint_args.clean_lines.elided[cpplint_args.linenum]
    matched = regex.match(line)
    if matched:
        cpplint_args.error(
            cpplint_args.filename, cpplint_args.linenum, error.type, 4,
            f'Error: {error.message} at {matched.group(0).strip()}')


# Matches OSP_CHECK(foo.is_value()) or OSP_DCHECK(foo.is_value())
_RE_PATTERN_VALUE_CHECK = re.compile(
    r'\s*OSP_D?CHECK\([^)]*\.is_value\(\)\);\s*')


def _CheckNoValueDchecks(filename, clean_lines, linenum, error):
    """Checks that there are no OSP_DCHECK(foo.is_value()) instances.

    filename: The name of the current file.
    clean_lines: A CleansedLines instance containing the file.
    linenum: The number of the line to check.
    error: The function to call with any errors found.
    """
    cpplint_args = CpplintArgs(filename, clean_lines, linenum, error)
    error_to_return = Error('runtime/is_value_dchecks',
                            'Unnecessary CHECK for ErrorOr::is_value()')

    _CheckNoRegexMatches(_RE_PATTERN_VALUE_CHECK, cpplint_args,
                         error_to_return)


# Matches Foo(Foo&&) when not followed by noexcept.
_RE_PATTERN_MOVE_WITHOUT_NOEXCEPT = re.compile(
    r'\s*(?P<classname>\w+)\((?P=classname)&&[^)]*\)\s*(?!noexcept)\s*[{;=]')


def _CheckNoexceptOnMove(filename, clean_lines, linenum, error):
    """Checks that move constructors are declared with 'noexcept'.

    filename: The name of the current file.
    clean_lines: A CleansedLines instance containing the file.
    linenum: The number of the line to check.
    error: The function to call with any errors found.
    """
    cpplint_args = CpplintArgs(filename, clean_lines, linenum, error)
    error_to_return = Error('runtime/noexcept',
                            'Move constructor not declared \'noexcept\'')

    # We only check headers as noexcept is meaningful on declarations, not
    # definitions.  This may skip some definitions in .cc files though.
    _CheckNoRegexMatches(_RE_PATTERN_MOVE_WITHOUT_NOEXCEPT, cpplint_args,
                         error_to_return, False)


# Matches "namespace <foo> {". Since we only check one line at a time, we
# need to call this twice.
_RE_PATTERN_UNNESTED_NAMESPACE = re.compile(
    r'namespace +\w+ +\{')


def _CheckUnnestedNamespaces(filename, clean_lines, linenum, error):
    """Checks that nestable namespaces are nested.

    filename: The name of the current file.
    clean_lines: A CleansedLines instance containing the file.
    linenum: The number of the line to check.
    error: The function to call with any errors found.
    """

    # If we have a match for a namespace on this line, check the next line for
    # an nestable namespace declaration.
    re = _RE_PATTERN_UNNESTED_NAMESPACE
    if re.match(clean_lines.elided[linenum]):
        cpplint_args = CpplintArgs(filename, clean_lines, linenum + 1, error)
        error_to_return = Error('runtime/nested_namespace',
                                'Please nest namespaces when possible.')
        _CheckNoRegexMatches(re, cpplint_args, error_to_return)


# Gives additional debug information whenever a linting error occurs.
_CPPLINT_VERBOSE_LEVEL = 4


# - We disable c++11 header checks since Open Screen allows them.
# - We disable whitespace/braces because of various false positives.
# - There are some false positives with 'explicit' checks, but it's useful
#   enough to keep.
# - We add a custom check for 'noexcept' usage.
def _CheckChangeLintsClean(input_api, output_api):
    """Checks that all '.cc' and '.h' files pass cpplint.py."""
    cpplint = input_api.cpplint
    # Directive that allows access to a protected member _XX of a client class.
    # pylint: disable=protected-access
    cpplint._cpplint_state.ResetErrorCounts()

    cpplint._SetFilters('-build/c++11,-whitespace/braces')
    files = [
        f.AbsoluteLocalPath() for f in input_api.AffectedSourceFiles(None)
    ]

    for file_name in files:
        cpplint.ProcessFile(file_name, _CPPLINT_VERBOSE_LEVEL, [
            _CheckNoexceptOnMove, _CheckNoValueDchecks,
            _CheckUnnestedNamespaces
        ])

    if cpplint._cpplint_state.error_count:
        if input_api.is_committing:
            res_type = output_api.PresubmitError
        else:
            res_type = output_api.PresubmitPromptWarning
        return [res_type('Changelist failed cpplint.py check.')]

    return []


def _CheckLuciCfgLint(input_api, output_api):
    """Check that the luci configs pass the linter."""
    path = os.path.join('infra', 'config', 'global', 'main.star')
    if not input_api.AffectedSourceFiles(
            lambda f: os.path.samefile(f.AbsoluteLocalPath(), path)):
        return []

    result = []
    result.extend(input_api.RunTests([input_api.Command(
        'lucicfg lint',
        [
            'lucicfg' if not input_api.is_windows else 'lucicfg.bat', 'lint',
            path, '--log-level', 'debug' if input_api.verbose else 'warning'
        ],
        {
            'stderr': input_api.subprocess.STDOUT,
            'shell': input_api.is_windows,  # to resolve *.bat
            'cwd': input_api.PresubmitLocalPath(),
        },
        output_api.PresubmitError)]))
    return result


def _CommonChecks(input_api, output_api):
    """Performs a list of checks that should be used for both presubmission and
       upload validation.
    """
    # PanProjectChecks include:
    #   CheckLongLines (@ 80 cols)
    #   CheckChangeHasNoTabs
    #   CheckChangeHasNoStrayWhitespace
    #   CheckChangeWasUploaded (if committing)
    #   CheckChangeHasDescription
    #   CheckDoNotSubmitInDescription
    #   CheckDoNotSubmitInFiles
    #   CheckLicenses
    results = input_api.canned_checks.PanProjectChecks(input_api,
                                                       output_api,
                                                       owners_check=False)

    # No carriage return characters, files end with one EOL (\n).
    results.extend(
        input_api.canned_checks.CheckChangeHasNoCrAndHasOnlyOneEol(
            input_api, output_api))

    # Ensure code change is gender inclusive.
    results.extend(
        input_api.canned_checks.CheckGenderNeutral(input_api, output_api))

    # Ensure code change to do items uses TODO(bug) or TODO(user) format.
    #  TODO(bug) is generally preferred.
    results.extend(
        input_api.canned_checks.CheckChangeTodoHasOwner(input_api, output_api))

    # Ensure code change passes linter cleanly.
    results.extend(_CheckChangeLintsClean(input_api, output_api))

    # Ensure code change has already had clang-format ran.
    results.extend(
        input_api.canned_checks.CheckPatchFormatted(input_api,
                                                    output_api,
                                                    bypass_warnings=False))

    # Ensure code change has had GN formatting ran.
    results.extend(
        input_api.canned_checks.CheckGNFormatted(input_api, output_api))

    # Run buildtools/checkdeps on code change.
    results.extend(_CheckDeps(input_api, output_api))

    # Ensure the LUCI configs pass the linter.
    results.extend(_CheckLuciCfgLint(input_api, output_api))

    # Run tools/licenses on code change.
    results.extend(_CheckLicenses(input_api, output_api))

    return results


def CheckChangeOnUpload(input_api, output_api):
    """Checks the changelist whenever there is an upload (`git cl upload`)"""
    # We always run the OnCommit checks, as well as some additional checks.
    results = CheckChangeOnCommit(input_api, output_api)
    results.extend(
        input_api.canned_checks.CheckChangedLUCIConfigs(input_api, output_api))
    return results


def CheckChangeOnCommit(input_api, output_api):
    """Checks the changelist whenever there is commit (`git cl commit`)"""
    return _CommonChecks(input_api, output_api)
