# Pylint checker for pointless class attributes overrides.
#
# Copyright (C) 2014  Red Hat, Inc.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions of
# the GNU General Public License v.2, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY expressed or implied, including the implied warranties of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
# Public License for more details.  You should have received a copy of the
# GNU General Public License along with this program; if not, write to the
# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
# source code or documentation are not subject to the GNU General Public
# License and may only be used or replicated with the express permission of
# Red Hat, Inc.
#
# Red Hat Author(s): Anne Mulhern <amulhern@redhat.com>
#

import abc
import astroid

from pylint.checkers import BaseChecker
from pylint.checkers.utils import check_messages
from pylint.interfaces import IAstroidChecker


class PointlessData(object, metaclass=abc.ABCMeta):

    _DEF_CLASS = abc.abstractproperty(doc="Class of interesting definitions.")
    message_id = abc.abstractproperty(doc="Pylint message identifier.")

    @classmethod
    @abc.abstractmethod
    def _retain_node(cls, node, restrict=True):
        """ Determines whether to retain a node for the analysis.

            :param node: an AST node
            :type node: astroid.Class
            :param restrict bool: True if results returned should be restricted
            :returns: True if the node should be kept, otherwise False
            :rtype: bool

            Restricted nodes are candidates for being marked as overridden.
            Only restricted nodes are put into the initial pool of candidates.
        """
        raise NotImplementedError()

    @staticmethod
    @abc.abstractmethod
    def _extract_value(node):
        """ Return the node that contains the assignment's value.

            :param node: an AST node
            :type node: astroid.Class
            :returns: the node corresponding to the value
            :rtype: bool
        """
        raise NotImplementedError()

    @staticmethod
    @abc.abstractmethod
    def _extract_targets(node):
        """ Generates the names being assigned to.

            :param node: an AST node
            :type node: astroid.Class
            :returns: a list of assignment target names
            :rtype: generator of str
        """
        raise NotImplementedError()

    @classmethod
    def get_data(cls, node, restrict=True):
        """ Find relevant nodes for this analysis.

            :param node: an AST node
            :type node: astroid.Class
            :param restrict bool: True if results returned should be restricted

            :rtype: generator of astroid.Class
            :returns: a generator of interesting nodes.

            Note that all nodes returned are guaranteed to be instances of
            some class in self._DEF_CLASS.
        """
        nodes = (n for n in node.body if isinstance(n, cls._DEF_CLASS))
        for n in nodes:
            if cls._retain_node(n, restrict):
                for name in cls._extract_targets(n):
                    yield (name, cls._extract_value(n))

    @classmethod
    @abc.abstractmethod
    def check_equal(cls, node, other):
        """ Check whether the two nodes are considered equal.

            :param node: some ast node
            :param other: some ast node

            :rtype: bool
            :returns: True if the nodes are considered equal, otherwise False

            If the method returns True, the nodes are actually equal, but it
            may return False when the nodes are equal.
        """
        raise NotImplementedError()


class PointlessFunctionDefinition(PointlessData):
    """ Looking for pointless function definitions. """

    _DEF_CLASS = astroid.FunctionDef
    message_id = "W9952"

    @classmethod
    def _retain_node(cls, node, restrict=True):
        return not restrict or \
           (len(node.body) == 1 and isinstance(node.body[0], astroid.Pass))

    @classmethod
    def check_equal(cls, node, other):
        return len(node.body) == 1 and isinstance(node.body[0], astroid.Pass) and \
           len(other.body) == 1 and isinstance(other.body[0], astroid.Pass)

    @staticmethod
    def _extract_value(node):
        return node

    @staticmethod
    def _extract_targets(node):
        yield node.name


class PointlessAssignment(PointlessData):

    _DEF_CLASS = astroid.Assign
    message_id = "W9951"

    _VALUE_CLASSES = (
        astroid.Call,
        astroid.Const,
        astroid.Dict,
        astroid.List,
        astroid.Tuple
    )

    @classmethod
    def _retain_node(cls, node, restrict=True):
        return not restrict or isinstance(node.value, cls._VALUE_CLASSES)

    @classmethod
    def check_equal(cls, node, other):
        # Allow the type() checks so that only an exact match will pass
        # pylint: disable=unidiomatic-typecheck
        if type(node) != type(other):
            return False
        if isinstance(node, astroid.Const):
            return node.value == other.value
        if isinstance(node, (astroid.List, astroid.Tuple)):
            return len(node.elts) == len(other.elts) and \
               all(cls.check_equal(n, o) for (n, o) in zip(node.elts, other.elts))
        if isinstance(node, astroid.Dict):
            return len(node.items) == len(other.items) and \
               all(cls.check_equal(n, o) for (n, o) in zip(sorted(node.items), sorted(other.items)))
        if isinstance(node, astroid.Call):
            if type(node.func) != type(other.func):
                return False

            if isinstance(node.func, astroid.Attribute):
                if node.func.attrname != other.func.attrname or \
                   type(node.func.expr) != type(other.func.expr) or \
                   node.func.expr.name != other.func.expr.name:
                    return False
            elif isinstance(node.func, astroid.Name):
                if node.func.name != other.func.name:
                    return False
            else:
                return False

            return node.starargs == other.starargs and \
               node.kwargs == other.kwargs and \
               node.args == other.args

        return False

    @staticmethod
    def _extract_value(node):
        return node.value

    @staticmethod
    def _extract_targets(node):
        for target in node.targets:
            yield target.name


class PointlessClassAttributeOverrideChecker(BaseChecker):
    """ If the nearest definition of the class attribute in the MRO assigns
        it the same value, then the overriding definition is said to be
        pointless.

        The algorithm for detecting a pointless attribute override is the following.

        * For each class, C:
           - For each attribute assignment,
               name_1 = name_2 ... name_n = l (where l is a literal):
              * For each n in (n_1, n_2):
                - Traverse the linearization of the MRO until the first
                   matching assignment n = l' is identified. If l is equal to l',
                   then consider that the assignment to l in C is a
                   pointless override.

        The algorithm for detecting a pointless method override has the same
        general structure, and the same defects discussed below.

        Note that this analysis is neither sound nor complete. It is unsound
        under multiple inheritance. Consider the following class hierarchy::

            class A(object):
                _attrib = False

            class B(A):
                _attrib = False

            class C(A):
                _attrib = True

            class D(B,C):
                pass

        In this  case, starting from B, B._attrib = False would be considered
        pointless. However, for D the MRO is B, C, A, and removing the assignment
        B._attrib = False would change the inherited value of D._attrib from
        False to True.

        The analysis is incomplete because it will find some values unequal when
        actually they are equal.

        The analysis is both incomplete and unsound because it expects that
        assignments will always be made by means of the same syntax.
    """

    __implements__ = (IAstroidChecker,)

    name = "pointless class attribute override checker"
    msgs = {
       "W9951":
       (
          "Assignment to class attribute %s overrides identical assignment in ancestor %s.",
          "pointless-class-attribute-override",
          "Assignment to class attribute  that overrides assignment in ancestor that assigns identical value has no effect."
       ),
       "W9952":
       (
          "Definition of %s method overrides identical method definition in ancestor %s.",
          "pointless-method-definition-override",
          "Overriding empty method definition with another empty method definition has no effect."
       )
    }

    @check_messages("W9951", "W9952")
    def visit_class(self, node):
        for checker in (PointlessAssignment, PointlessFunctionDefinition):
            for (name, value) in checker.get_data(node):
                for a in node.ancestors():
                    match = next((v for (n, v) in checker.get_data(a, False) if n == name), None)
                    if match is not None:
                        if checker.check_equal(value, match):
                            self.add_message(checker.message_id, node=value, args=(name,a.name))
                        break


def register(linter):
    linter.register_checker(PointlessClassAttributeOverrideChecker(linter))
