#! /usr/bin/env python
#
# A report generator for gcov 3.4
#
# This routine generates a format that is similar to the format generated 
# by the Python coverage.py module.  This code is similar to the 
# data processing performed by lcov's geninfo command.  However, we
# don't worry about parsing the *.gcna files, and backwards compatibility for
# older versions of gcov is not supported.
#
# Copyright (2008) Sandia Corporation. Under the terms of Contract
# DE-AC04-94AL85000 with Sandia Corporation, the U.S. Government
# retains certain rights in this software.
# 
# Outstanding issues
#   - verify that gcov 3.4 or newer is being used
#   - verify support for symbolic links
#
#  _________________________________________________________________________
#
#  FAST: Python tools for software testing.
#  Copyright (c) 2008 Sandia Corporation.
#  This software is distributed under the BSD License.
#  Under the terms of Contract DE-AC04-94AL85000 with Sandia Corporation,
#  the U.S. Government retains certain rights in this software.
#  For more information, see the FAST README.txt file.
#  _________________________________________________________________________
#

import sys
from optparse import OptionParser
import subprocess
import glob
import os
import re
import copy
import xml.dom.minidom

__version__ = "2.0.prerelease"
gcov_cmd = "gcov"

output_re = re.compile("creating '(.*)'$")
source_re = re.compile("cannot open source file")

#
# Container object for coverage statistics
#
class CoverageData(object):

    def __init__(self, fname, uncovered, covered, branches, noncode):
        self.fname=fname
        # Shallow copies are cheap & "safe" because the caller will
        # throw away their copies of covered & uncovered after calling
        # us exactly *once*
        self.uncovered = copy.copy(uncovered)
        self.covered   = copy.copy(covered)
        self.noncode   = copy.copy(noncode)
        # But, a deep copy is required here
        self.all_lines = copy.deepcopy(uncovered)
        self.all_lines.update(covered.keys())
        self.branches = copy.deepcopy(branches)

    def update(self, uncovered, covered, branches, noncode):
        self.all_lines.update(uncovered)
        self.all_lines.update(covered.keys())
        self.uncovered.update(uncovered)
        self.noncode.intersection_update(noncode)
        for k in covered.keys():
            self.covered[k] = self.covered.get(k,0) + covered[k]
        for k in branches.keys():
            for b in branches[k]:
                d = self.branches.setdefault(k, {})
                d[b] = d.get(b, 0) + branches[k][b]
        self.uncovered.difference_update(self.covered.keys())

    def uncovered_str(self):
        if options.show_branch:
            # Don't do any aggregation on branch results
            tmp = []
            for line in self.branches.keys():
                for branch in self.branches[line]:
                    if self.branches[line][branch] == 0:
                        tmp.append(line)
                        break

            tmp.sort()
            return ",".join([str(x) for x in tmp]) or ""
        
        tmp = list(self.uncovered)
        if len(tmp) == 0:
            return ""

        tmp.sort()
        first = None
        last = None
        ranges=[]
        for item in tmp:
            #print "HERE",item
            if last is None:
                first=item
                last=item
            elif item == (last+1):
                last=item
            else:
                if len(self.noncode.intersection(range(last+1,item))) \
                       == item - last - 1:
                    last = item
                    continue
                
                if first==last:
                    ranges.append(str(first))
                else:
                    ranges.append(str(first)+"-"+str(last))
                first=item
                last=item
        if first==last:
            ranges.append(str(first))
        else:
            ranges.append(str(first)+"-"+str(last))
        return ",".join(ranges)

    def coverage(self):
        if ( options.show_branch ):
            total = 0
            cover = 0
            for line in self.branches.keys():
                for branch in self.branches[line].keys():
                    total += 1
                    cover += self.branches[line][branch] > 0 and 1 or 0
        else:
            total = len(self.all_lines)
            cover = len(self.covered)
            
        percent = total and str(int(100.0*cover/total)) or "--"
        return (total, cover, percent)

    def summary(self,prefix):
        if prefix is not None:
            if prefix[-1] == os.sep:
                tmp = self.fname[len(prefix):]
            else:
                tmp = self.fname[(len(prefix)+1):]
        else:
            tmp=self.fname
        tmp = tmp.ljust(40)
        if len(tmp) > 40:
            tmp=tmp+"\n"+" "*40

        (total, cover, percent) = self.coverage()
        return ( total, cover,
                 tmp + str(total).rjust(8) + str(cover).rjust(8) + \
                 percent.rjust(6) + "%   " + self.uncovered_str() )


def search_file(expr, path=None, abspath=False, follow_links=False):
    """
    Given a search path, recursively descend to find files that match a
    regular expression.

    Can specify the following options:
       path - The directory that is searched recursively
       executable_extension - This string is used to see if there is an
           implicit extension in the filename
       executable - Test if the file is an executable (default=False)
       isfile - Test if the file is file (default=True)
    """
    ans = []
    pattern = re.compile(expr)
    if path is None or path == ".":
        path = os.getcwd()
    elif not os.path.exists(path):
        raise IOError, "Unknown directory '"+path+"'"
    for root, dirs, files in os.walk(path, topdown=True):
        for name in files:
           if pattern.match(name):
                name = os.path.join(root,name)
                if follow_links and os.path.islink(name):
                    ans.append( os.path.abspath(os.readlink(name)) )
                elif abspath:
                    ans.append( os.path.abspath(name) )
                else:
                    ans.append( name )
    return ans


#
# Get the list of datafiles in the directories specified by the user
#
def get_datafiles(flist, options, ext="gcda"):
    allfiles=[]
    for dir in flist:
        if options.verbose:
            print "Scanning directory "+dir+" for "+ext+" files..."
        files = search_file(".*\."+ext, dir, abspath=True, follow_links=True)
        if options.verbose:
            print "Found %d files " % len(files)
        allfiles += files
    return allfiles


def process_gcov_data(file, covdata, options):
    INPUT = open(file,"r")
    #
    # Get the filename
    #
    line = INPUT.readline()
    segments=line.split(":")
    fname = (segments[-1]).strip()
    if fname[0] != os.sep:
        #line = INPUT.readline()
        #segments=line.split(":")
        #fname = os.path.dirname((segments[-1]).strip())+os.sep+fname
        fname = os.path.abspath(fname)
    if options.verbose:
        print "Parsing coverage data for file "+fname
    #
    # Return if the filename does not match the filter
    #
    if options.filter is not None and not options.filter.match(fname):
        if options.verbose:
            print "  Filtering coverage data for file "+fname
        return
    #
    # Return if the filename matches the exclude pattern
    #
    for i in range(0,len(options.exclude)):
        if options.exclude[i].match(fname[filter_pattern_size:]) or \
               options.exclude[i].match(fname):
            if options.verbose:
                print "  Excluding coverage data for file "+fname
            return
    #
    # Parse each line, and record the lines
    # that are uncovered
    #
    noncode   = set()
    uncovered = set()
    covered   = {}
    branches  = {}
    #first_record=True
    lineno = 0
    for line in INPUT:
        segments=line.split(":")
        tmp = segments[0].strip()
        try:
            lineno = int(segments[1].strip())
        except:
            pass # keep previous line number!
            
        if tmp[0] == '#':
            uncovered.add( lineno )
        elif tmp[0] in "0123456789":
            covered[lineno] = int(segments[0].strip())
        elif tmp[0] == '-':
            # remember certain non-executed lines
            code = segments[2].strip()
            if len(code) == 0 or code == "{" or code == "}" or \
               code.startswith("//") or code == 'else':
                noncode.add( lineno )
        elif tmp.startswith('branch'):
            fields = line.split()
            try:
                count = int(fields[3])
                branches.setdefault(lineno, {})[int(fields[1])] = count
            except:
                # We ignore branches that were "never executed"
                pass
        elif tmp.startswith('call'):
            pass
        elif tmp.startswith('function'):
            pass
        elif tmp[0] == 'f':
            pass
            #if first_record:
                #first_record=False
                #uncovered.add(prev)
            #if prev in uncovered:
                #tokens=re.split('[ \t]+',tmp)
                #if tokens[3] != "0":
                    #uncovered.remove(prev)
            #prev = int(segments[1].strip())
            #first_record=True
        else:
            print "UNKNOWN LINE DATA:",tmp
    #
    # If the file is already in covdata, then we
    # remove lines that are covered here.  Otherwise,
    # initialize covdata
    #
    #print "HERE",fname
    #print "HERE uncovered",uncovered
    #print "HERE   covered",covered
    if not fname in covdata:
        covdata[fname] = CoverageData(fname,uncovered,covered,branches,noncode)
    else:
        #print "HERE B uncovered",covdata[fname].uncovered
        #print "HERE B   covered",covdata[fname].covered
        covdata[fname].update(uncovered,covered,branches,noncode)
        #print "HERE A uncovered",covdata[fname].uncovered
        #print "HERE A   covered",covdata[fname].covered
    INPUT.close()

#
# Process a datafile and run gcov with the corresponding arguments
#
def process_datafile(filename, covdata, options):
    #
    # Launch gcov
    #
    (dirname,base) = os.path.split(filename)
    (name,ext) = os.path.splitext(base)
    prevdir = os.getcwd()
    objdir = ''
    (head, tail) = os.path.split(name)
    errors=[]

    while True:
        os.chdir(dirname)
        cmd = [gcov_cmd, "--branch-counts", "--branch-probabilities", 
               "--preserve-paths"]
        if objdir:
            cmd.extend(["--object-directory", objdir])
        cmd.append(tail)

        if options.verbose:
            print "running gcov: '"+" ".join(cmd)+"' in '"+os.getcwd()+"'"
        (out, err) = subprocess.Popen( cmd,
                                       stdout=subprocess.PIPE,
                                       stderr=subprocess.PIPE ).communicate()

        # find the files that gcov created
        gcov_files = []
        for line in out.split("\n"):
            found = output_re.search(line)
            if found is not None:
                gcov_files.append(found.group(1))
        #print "Output files\n" + "\n".join(gcov_files)

        if source_re.search(err):
            # gcov tossed errors: try running from 1 dir up the hierarchy
            errors.append(err)
            (tmp1, tmp2) = os.path.split(dirname)
            if ( tmp1 == dirname ):
                print "GCOV produced the following errors:\n   %s" \
                    "(gcovr could not infer a working directory " \
                    "that resolved it.)" % ( "   ".join(errors) )
                break
            dirname = tmp1
            objdir = objdir and os.path.join(tmp2, objdir) or tmp2
            # something went wrong with gcov; remove all generated files
            # and try again (regardless of options.keep)
            for fname in gcov_files:
                os.remove(fname)
            continue

        #
        # Process *.gcov files
        #
        #for fname in glob.glob("*.gcov"):
        for fname in gcov_files:
            process_gcov_data(fname, covdata, options)
            if not options.keep:
                os.remove(fname)
        break

    os.chdir(prevdir)
    if options.delete:
        os.remove(filename)

#
# Produce the classic gcovr text report
#
def print_text_report(covdata):
    def _num_uncovered(key):
        (total, covered, percent) = covdata[key].coverage()
        return total - covered
    def _percent_uncovered(key):
        (total, covered, percent) = covdata[key].coverage()
        if covered:
            return -1.0*covered/total
        else:
            return total or 1e6
    def _alpha(key):
        return key

    total_lines=0
    total_covered=0
    # Header
    print "-"*78
    a = options.show_branch and "Branch" or "Lines"
    b = options.show_branch and "Taken" or "Exec"
    print "File".ljust(40) + a.rjust(8) + b.rjust(8)+ "  Cover   Missing"
    print "-"*78

    # Data
    keys = covdata.keys()
    keys.sort(key=options.sort_uncovered and _num_uncovered or \
              options.sort_percent and _percent_uncovered or _alpha)
    for key in keys:
        (t, n, txt) = covdata[key].summary(options.root)
        total_lines += t
        total_covered += n
        print txt

    # Footer & summary
    print "-"*78
    percent = total_lines and str(int(100.0*total_covered/total_lines)) or "--"
    print "TOTAL".ljust(40) + str(total_lines).rjust(8) + \
          str(total_covered).rjust(8) + str(percent).rjust(6)+"%"
    print "-"*78

#
# Produce an XML report in the Cobertura format
#
def print_xml_report(covdata):

    prefix = filter_pattern_size

    impl = xml.dom.minidom.getDOMImplementation()
    docType = impl.createDocumentType(
        "coverage", None,
        "http://cobertura.sourceforge.net/xml/coverage-03.dtd" )
    doc = impl.createDocument(None, "coverage", docType)
    root = doc.documentElement

    if options.root is not None:
        source = doc.createElement("source")
        source.appendChild(doc.createTextNode(options.root))
        sources = doc.createElement("sources")
        sources.appendChild(source)
        root.appendChild(sources)

    packageXml = doc.createElement("packages")
    root.appendChild(packageXml)
    packages = {}

    keys = covdata.keys()
    keys.sort()
    for f in keys:
        data = covdata[f]
        (dir, fname) = os.path.split(f)
        dir = dir[prefix:]
        
        package = packages.setdefault(
            dir, [ doc.createElement("package"), {},
                   0, 0, 0, 0 ] )
        c = doc.createElement("class")
        lines = doc.createElement("lines")
        c.appendChild(lines)

        class_lines = 0
        class_hits = 0
        class_branches = 0
        class_branch_hits = 0
        for line in data.all_lines:
            hits = data.covered.get(line, 0)
            class_lines += 1
            if hits > 0:
                class_hits += 1
            l = doc.createElement("line")
            l.setAttribute("number", str(line))
            l.setAttribute("hits", str(hits))
            branches = data.branches.get(line)
            if branches is None:
                l.setAttribute("branch", "false")
            else:
                b_hits = 0
                for v in branches.values():
                    if v > 0:
                        b_hits += 1
                coverage = 100*b_hits/len(branches)
                l.setAttribute("branch", "true")
                l.setAttribute( "condition-coverage",
                                "%i%% (%i/%i)" %
                                (coverage, b_hits, len(branches)) )
                cond = doc.createElement('condition')
                cond.setAttribute("number", "0")
                cond.setAttribute("type", "jump")
                cond.setAttribute("coverage", "%i%%" % ( coverage ) )
                class_branch_hits += b_hits
                class_branches += float(len(branches))
                conditions = doc.createElement("conditions")
                conditions.appendChild(cond)
                l.appendChild(conditions)
                
            lines.appendChild(l)

        className = fname.replace('.', '_')
        c.setAttribute("name", className)
        c.setAttribute("filename", dir+os.sep+fname)
        c.setAttribute("line-rate", str(class_hits / (1.0*class_lines or 1.0)))
        c.setAttribute( "branch-rate",
                        str(class_branch_hits / (1.0*class_branches or 1.0)) )
        c.setAttribute("complexity", "0.0")

        package[1][className] = c
        package[2] += class_hits
        package[3] += class_lines
        package[4] += class_branch_hits
        package[5] += class_branches

    for packageName, packageData in packages.items():
        package = packageData[0];
        packageXml.appendChild(package)
        classes = doc.createElement("classes")
        package.appendChild(classes)
        classNames = packageData[1].keys()
        classNames.sort()
        for className in classNames:
            classes.appendChild(packageData[1][className])
        package.setAttribute("name", packageName.replace(os.sep, '.'))
        package.setAttribute("line-rate", str(packageData[2]/(1.0*packageData[3] or 1.0)))
        package.setAttribute( "branch-rate", str(packageData[4] / (1.0*packageData[5] or 1.0) ))
        package.setAttribute("complexity", "0.0")


    xmlString = doc.toprettyxml()
    print xmlString
    #xml.dom.ext.PrettyPrint(doc)


##
## MAIN
##

#
# Create option parser
#
parser = OptionParser()
parser.add_option("--version",
        help="Print the version number, then exit",
        action="store_true",
        dest="version",
        default=False)
parser.add_option("-v","--verbose",
        help="Print progress messages",
        action="store_true",
        dest="verbose",
        default=False)
parser.add_option("-o","--output",
        help="Print output to this filename",
        action="store",
        dest="output",
        default=None)
parser.add_option("-k","--keep",
        help="Keep temporary gcov files",
        action="store_true",
        dest="keep",
        default=False)
parser.add_option("-d","--delete",
        help="Delete the coverage files after they are processed",
        action="store_true",
        dest="delete",
        default=False)
parser.add_option("-f","--filter",
        help="Keep only the data files that match this regular expression",
        action="store",
        dest="filter",
        default=None)
parser.add_option("-e","--exclude",
        help="Exclude data files that match this regular expression",
        action="append",
        dest="exclude",
        default=[])
parser.add_option("-r","--root",
        help="Defines the root directory.  This is used to filter the files, and to standardize the output.",
        action="store",
        dest="root",
        default=None)
parser.add_option("-x","--xml",
        help="Generate XML instead of the normal tabular output.",
        action="store_true",
        dest="xml",
        default=None)
parser.add_option("-b","--branches",
        help="Tabulate the branch coverage instead of the line coverage.",
        action="store_true",
        dest="show_branch",
        default=None)
parser.add_option("-u","--sort-uncovered",
        help="Sort entries by increasing number of uncovered lines.",
        action="store_true",
        dest="sort_uncovered",
        default=None)
parser.add_option("-p","--sort-percentage",
        help="Sort entries by decreasing percentage of covered lines.",
        action="store_true",
        dest="sort_percent",
        default=None)
parser.usage="gcovr [options]"
parser.description="A utility to run gcov and generate a simple report that summarizes the coverage"
#
# Process options
#
(options, args) = parser.parse_args(args=sys.argv)
if options.version:
    print "gcovr "+__version__
    print ""
    print "Copyright (2008) Sandia Corporation. Under the terms of Contract "
    print "DE-AC04-94AL85000 with Sandia Corporation, the U.S. Government "
    print "retains certain rights in this software."
    sys.exit(0)
#
# Setup filter
#
for i in range(0,len(options.exclude)):
    options.exclude[i] = re.compile(options.exclude[i])
if options.filter is not None:
    options.filter = re.compile(options.filter)
elif options.root is not None:
    #if options.root[0] != os.sep:
    #    dir=os.getcwd()+os.sep+options.root
    #    dir=os.path.abspath(dir)
    #    options.root=dir
    #else:
    #    options.root=os.path.abspath(options.root)
    options.root=os.path.abspath(options.root)
    #print "HERE",options.root
    options.filter = re.compile(options.root.replace("\\","\\\\"))
if options.filter is not None:
    if options.filter.pattern[-1] == os.sep:
        filter_pattern_size = len(options.filter.pattern)
    else:
        filter_pattern_size = len(options.filter.pattern) + len(os.sep)
else:
    filter_pattern_size = 0
#
# Get data files
#
if len(args) == 1:
    datafiles = get_datafiles(["."], options)
else:
    datafiles = get_datafiles(args[1:], options)
#
# Get coverage data
#
covdata = {}
for file in datafiles:
    process_datafile(file,covdata,options)
if options.verbose:
    print "Gathered coveraged data for "+str(len(covdata))+" files"
#
# Print report
#
if options.xml:
    print_xml_report(covdata)
else:
    print_text_report(covdata)
