# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt

"""Parser.py: a main for invoking code in coverage/parser.py"""


import collections
import dis
import glob
import optparse
import os
import re
import sys
import textwrap
import types

from coverage.parser import PythonParser
from coverage.python import get_python_source


class ParserMain:
    """A main for code parsing experiments."""

    def main(self, args):
        """A main function for trying the code from the command line."""

        parser = optparse.OptionParser()
        parser.add_option(
            "-d", action="store_true", dest="dis",
            help="Disassemble"
        )
        parser.add_option(
            "-R", action="store_true", dest="recursive",
            help="Recurse to find source files"
        )
        parser.add_option(
            "-q", action="store_true", dest="quiet",
            help="Suppress output"
        )
        parser.add_option(
            "-s", action="store_true", dest="source",
            help="Show analyzed source"
        )
        parser.add_option(
            "-t", action="store_true", dest="tokens",
            help="Show tokens"
        )

        options, args = parser.parse_args()
        if options.recursive:
            if args:
                root = args[0]
            else:
                root = "."
            for root, _, _ in os.walk(root):
                for f in glob.glob(root + "/*.py"):
                    if not options.quiet:
                        print(f"Parsing {f}")
                    self.one_file(options, f)
        elif not args:
            parser.print_help()
        else:
            self.one_file(options, args[0])

    def one_file(self, options, filename):
        """Process just one file."""
        # `filename` can have a line number suffix. In that case, extract those
        # lines, dedent them, and use that.  This is for trying test cases
        # embedded in the test files.
        if match := re.search(r"^(.*):(\d+)-(\d+)$", filename):
            filename, start, end = match.groups()
            start, end = int(start), int(end)
        else:
            start = end = None

        try:
            text = get_python_source(filename)
            if start is not None:
                lines = text.splitlines(True)
                text = textwrap.dedent("".join(lines[start-1:end]).replace("\\\\", "\\"))
            pyparser = PythonParser(text, filename=filename, exclude=r"no\s*cover")
            pyparser.parse_source()
        except Exception as err:
            print(f"{err}")
            return

        if options.dis:
            print("Main code:")
            disassemble(pyparser.text)

        arcs = pyparser.arcs()

        if options.source or options.tokens:
            pyparser.show_tokens = options.tokens
            pyparser.parse_source()

            if options.source:
                arc_chars = self.arc_ascii_art(arcs)
                if arc_chars:
                    arc_width = max(len(a) for a in arc_chars.values())

                exit_counts = pyparser.exit_counts()

                for lineno, ltext in enumerate(pyparser.text.splitlines(), start=1):
                    marks = [' '] * 6
                    a = ' '
                    if lineno in pyparser.raw_statements:
                        marks[0] = '-'
                    if lineno in pyparser.statements:
                        marks[1] = '='
                    exits = exit_counts.get(lineno, 0)
                    if exits > 1:
                        marks[2] = str(exits)
                    if lineno in pyparser.raw_docstrings:
                        marks[3] = '"'
                    if lineno in pyparser.raw_excluded:
                        marks[4] = 'X'
                    elif lineno in pyparser.excluded:
                        marks[4] = '×'
                    if lineno in pyparser._multiline.values():
                        marks[5] = 'o'
                    elif lineno in pyparser._multiline.keys():
                        marks[5] = '.'

                    if arc_chars:
                        a = arc_chars[lineno].ljust(arc_width)
                    else:
                        a = ""

                    if not options.quiet:
                        print("%4d %s%s %s" % (lineno, "".join(marks), a, ltext))

    def arc_ascii_art(self, arcs):
        """Draw arcs as ascii art.

        Returns a dictionary mapping line numbers to ascii strings to draw for
        that line.

        """
        plus_ones = set()
        arc_chars = collections.defaultdict(str)
        for lfrom, lto in sorted(arcs):
            if lfrom < 0:
                arc_chars[lto] += 'v'
            elif lto < 0:
                arc_chars[lfrom] += '^'
            else:
                if lfrom == lto - 1:
                    plus_ones.add(lfrom)
                    arc_chars[lfrom] += ""      # ensure this line is in arc_chars
                    continue
                if lfrom < lto:
                    l1, l2 = lfrom, lto
                else:
                    l1, l2 = lto, lfrom
                w = first_all_blanks(arc_chars[l] for l in range(l1, l2+1))
                for l in range(l1, l2+1):
                    if l == lfrom:
                        ch = '<'
                    elif l == lto:
                        ch = '>'
                    else:
                        ch = '|'
                    arc_chars[l] = set_char(arc_chars[l], w, ch)

        # Add the plusses as the first character
        for lineno, arcs in arc_chars.items():
            arc_chars[lineno] = (
                ("+" if lineno in plus_ones else " ") +
                arcs
            )

        return arc_chars


def all_code_objects(code):
    """Iterate over all the code objects in `code`."""
    stack = [code]
    while stack:
        # We're going to return the code object on the stack, but first
        # push its children for later returning.
        code = stack.pop()
        stack.extend(c for c in code.co_consts if isinstance(c, types.CodeType))
        yield code


def disassemble(text):
    """Disassemble code, for ad-hoc experimenting."""

    code = compile(text, "", "exec", dont_inherit=True)
    for code_obj in all_code_objects(code):
        if text:
            srclines = text.splitlines()
        else:
            srclines = None
        print("\n%s: " % code_obj)
        upto = None
        for inst in dis.get_instructions(code_obj):
            if inst.starts_line is not None:
                if srclines:
                    upto = upto or inst.starts_line - 1
                    while upto <= inst.starts_line - 1:
                        print("{:>100}{}".format("", srclines[upto]))
                        upto += 1
                elif inst.offset > 0:
                    print("")
            line = inst._disassemble()
            print(f"{line:<70}")

    print("")


def set_char(s, n, c):
    """Set the nth char of s to be c, extending s if needed."""
    s = s.ljust(n)
    return s[:n] + c + s[n+1:]


def blanks(s):
    """Return the set of positions where s is blank."""
    return {i for i, c in enumerate(s) if c == " "}


def first_all_blanks(ss):
    """Find the first position that is all blank in the strings ss."""
    ss = list(ss)
    blankss = blanks(ss[0])
    for s in ss[1:]:
        blankss &= blanks(s)
    if blankss:
        return min(blankss)
    else:
        return max(len(s) for s in ss)


if __name__ == '__main__':
    ParserMain().main(sys.argv[1:])
