#!/usr/bin/env python

from __future__ import print_function

import os
import sys
import token
from tokenize import *
import argparse

parser = argparse.ArgumentParser(description='Look into Sage files for wrong doctests.')
parser.add_argument('filename', type=str, nargs='+', help='filename or a directory')
parser.add_argument('--only-bad', action='store_true', help='only print info for bad formatted files')
parser.add_argument('--summary', action='store_true', help='only print a short summary')

args = parser.parse_args()

# Collect coverage results for one file
class CoverageResults:
    def __init__(self, filename=""):
        """
        INPUT:

        - ``filename`` -- name of the file, only for display purposes.
        """
        self.no_doc = []
        self.no_test = []
        self.good = []
        self.possibly_wrong = []
        self.filename = filename

    def report(self):
        """
        Print coverage results.
        """
        num_functions = len(self.good) + len(self.no_doc) + len(self.no_test)
        if not num_functions:
            print("No functions in", self.filename)
            return

        score = (100.0 * len(self.good)) / float(num_functions)

        print("SCORE {}: {:.1f}% ({} of {})".format(self.filename, score, len(self.good), num_functions))

        if self.no_doc:
            print("\nMissing documentation:")
            for f in self.no_doc:
                print("     *", f)
        if self.no_test:
            print("\nMissing doctests:")
            for f in self.no_test:
                print("     *", f)

        if self.possibly_wrong:
            print("\nPossibly wrong (function name doesn't occur in doctests):")
            for f in self.possibly_wrong:
                print("     *", f)


    def handle_function(self, name, fullname, docstring):
        """
        Check coverage of one function and store result.

        INPUT:

        - ``name`` -- bare function name (e.g. "foo")

        - ``fullname`` -- complete function definition (e.g. "def foo(arg=None)")

        - ``docstring`` -- the docstring, or ``None`` if there is no docstring
        """
        # Skip certain names
        if name in ['__dealloc__', '__new__', '_']:
            return

        if not docstring:
            self.no_doc.append(fullname)
            return
        if not "sage: " in docstring:
            self.no_test.append(fullname)
            return

        # If the name is of the form _xxx_, then the doctest is always
        # considered indirect.
        if name[0] == "_" and name[-1] == "_":
            is_indirect = True
        else:
            is_indirect = "indirect doctest" in docstring

        if not is_indirect and not name in docstring:
            self.possibly_wrong.append(fullname)
        self.good.append(fullname)


    def check_file(self, f):
        """
        Check the coverage of one file.

        INPUT:

        - ``f``: an open file

        OUTPUT: ``self``
        """
        # Where are we in a function definition?
        BEGINOFLINE = 0   # Beginning of new logical line
        UNKNOWN = -99     # Not at all in a function definition
        DEFNAMES = 1      # In function definition before first open paren
        DEFARGS = 2       # In function arguments or between closing paren and final colon
        DOCSTRING = -1    # Looking for docstring

        state = BEGINOFLINE

        # Previous token type seen
        prevtyp = NEWLINE

        # Indentation level
        indent = 0

        # Indentation level of last "def" statement
        # or None if no such statement.
        defindent = None

        for (typ, tok, start, end, logical_line) in generate_tokens(f.readline):
            # Completely ignore comments or continuation newlines
            if typ == COMMENT or typ == NL:
                continue

            # Handle indentation
            if typ == INDENT:
                indent += 1
                continue
            elif typ == DEDENT:
                indent -= 1
                if (defindent is not None and indent <= defindent):
                    defindent = None
                continue

            # Check for "def" or "cpdef" ("cdef" functions don't need to be documented).
            # Skip nested functions (with indent > defindent).
            if state == BEGINOFLINE:
                if typ == NAME and (tok in ["def", "cpdef"]) and (defindent is None or indent <= defindent):
                    state = DEFNAMES
                    deffullname = "line %s: "%start[0]
                    defparen = 0  # Number of open parentheses
                else:
                    state = UNKNOWN

            if state == DOCSTRING:
                if typ != NEWLINE:
                    docstring = None
                    if typ == STRING:
                        docstring = tok
                    self.handle_function(defname, deffullname, docstring)
                    state = UNKNOWN

            if state == DEFNAMES:
                if typ == NAME:
                    if tok == "class":  # Make sure that cdef classes are ignored
                        state = UNKNOWN
                    # Last NAME token before opening parenthesis is
                    # the function name.
                    defname = tok
                elif tok == '(':
                    state = DEFARGS
                else:
                    state = UNKNOWN

            if state == DEFARGS:
                if tok == '(':
                    defparen += 1
                elif tok == ')':
                    defparen -= 1
                elif defparen == 0 and tok == ':':
                    state = DOCSTRING
                    defindent = indent
                elif typ == NEWLINE:
                    state = UNKNOWN

            if state > 0:
                # Append tok string to deffullname
                if prevtyp == NAME and typ == NAME:
                    deffullname += ' '
                elif prevtyp == OP and deffullname[-1] in ",":
                    deffullname += ' '
                deffullname += tok

            # New line?
            if state == UNKNOWN and typ == NEWLINE:
                state = BEGINOFLINE

            prevtyp = typ

        return self


# Data reported by --summary
good           = 0
no_doc         = 0
no_test        = 0
possibly_wrong = 0
bad_files      = []

first = True
def go(filename):
    r"""
    If ``filename`` is a file, launch the inspector on this file. If
    ``filename`` is a directory then recursively launch this function on the
    files it contains.
    """
    if os.path.isdir(filename):
        for F in sorted(os.listdir(filename)):
            go(os.path.join(filename, F))
    if not os.path.exists(filename):
        print("File %s does not exist."%filename, file=sys.stderr)
        sys.exit(1)

    if not (filename.endswith('.py')
            or filename.endswith('.pyx')
            or filename.endswith('.sage')):
        return

    with open(filename, 'r') as f:
        cr = CoverageResults(filename).check_file(f)
    bad = cr.no_doc or cr.no_test or cr.possibly_wrong

    # Update the global variables
    if args.summary:
        global good, no_doc, no_test, possibly_wrong, bad_files
        no_doc         += len(cr.no_doc)
        no_test        += len(cr.no_test)
        possibly_wrong += len(cr.possibly_wrong)
        good           += len(cr.good)
        if bad: bad_files.append(filename)
        return
    if not bad and args.only_bad:
        return

    global first
    if first:
        print('-'*72)
        first = False

    cr.report() # Print the report
    print('-'*72)

for arg in args.filename:
    go(arg)

if args.summary:
    num_functions = good + no_doc + no_test
    score = (100.0 * good) / float(num_functions)
    print("Global score: {:.1f}% ({} of {})\n".format(score, good, num_functions))
    print("{} files with wrong documentation".format(len(bad_files)))
    print("{} functions with no doc".format(no_doc))
    print("{} functions with no test".format(no_test))
    print("{} doctest are potentially wrong".format(possibly_wrong))
    print("\nFiles with wrong documentation:")
    print("-------------------------------")
    print("\n".join("  {}".format(filename) for filename in bad_files))
