diff options
author | rspangler@google.com <rspangler@google.com@0039d316-1c4b-4281-b951-d872f2087c98> | 2009-05-29 00:05:27 +0000 |
---|---|---|
committer | rspangler@google.com <rspangler@google.com@0039d316-1c4b-4281-b951-d872f2087c98> | 2009-05-29 00:05:27 +0000 |
commit | 0df95fd432f9023ce03548c2aba8ca4d774910f7 (patch) | |
tree | acbe04cf1e31e36219d04781ec54ae6b30a10de8 /tools/code_coverage | |
parent | e778c9cf2d6ffd74e4ed3bd88f381752c3c15a4a (diff) | |
download | chromium_src-0df95fd432f9023ce03548c2aba8ca4d774910f7.zip chromium_src-0df95fd432f9023ce03548c2aba8ca4d774910f7.tar.gz chromium_src-0df95fd432f9023ce03548c2aba8ca4d774910f7.tar.bz2 |
Major refactoring of Croc.
Add support for scanning missing source files for executable lines.
Add support for HTML output.
Now reports percent coverage.
BUG=none
TEST=by hand on experimental buildbot
Review URL: http://codereview.chromium.org/113980
git-svn-id: svn://svn.chromium.org/chrome/trunk/src@17141 0039d316-1c4b-4281-b951-d872f2087c98
Diffstat (limited to 'tools/code_coverage')
-rw-r--r-- | tools/code_coverage/croc.py | 229 | ||||
-rw-r--r-- | tools/code_coverage/croc_html.py | 453 | ||||
-rw-r--r-- | tools/code_coverage/croc_scan.py | 191 | ||||
-rw-r--r-- | tools/code_coverage/croc_scan_test.py | 219 | ||||
-rw-r--r-- | tools/code_coverage/croc_test.py | 343 |
5 files changed, 1215 insertions, 220 deletions
diff --git a/tools/code_coverage/croc.py b/tools/code_coverage/croc.py index b2bd33b..9d2f3ac 100644 --- a/tools/code_coverage/croc.py +++ b/tools/code_coverage/croc.py @@ -31,16 +31,19 @@ """Crocodile - compute coverage numbers for Chrome coverage dashboard.""" +import optparse import os import re import sys -from optparse import OptionParser +import croc_html +import croc_scan -class CoverageError(Exception): +class CrocError(Exception): """Coverage error.""" -class CoverageStatError(CoverageError): + +class CrocStatError(CrocError): """Error evaluating coverage stat.""" #------------------------------------------------------------------------------ @@ -57,7 +60,7 @@ class CoverageStats(dict): """ for k, v in coverage_stats.iteritems(): if k in self: - self[k] = self[k] + v + self[k] += v else: self[k] = v @@ -67,17 +70,19 @@ class CoverageStats(dict): class CoveredFile(object): """Information about a single covered file.""" - def __init__(self, filename, group, language): + def __init__(self, filename, **kwargs): """Constructor. Args: filename: Full path to file, '/'-delimited. - group: Group file belongs to. - language: Language for file. + kwargs: Keyword args are attributes for file. """ self.filename = filename - self.group = group - self.language = language + self.attrs = dict(kwargs) + + # Move these to attrs? + self.local_path = None # Local path to file + self.in_lcov = False # Is file instrumented? # No coverage data for file yet self.lines = {} # line_no -> None=executable, 0=instrumented, 1=covered @@ -102,10 +107,9 @@ class CoveredFile(object): # Add conditional stats if cov: self.stats['files_covered'] = 1 - if instr: + if instr or self.in_lcov: self.stats['files_instrumented'] = 1 - #------------------------------------------------------------------------------ @@ -128,7 +132,7 @@ class CoveredDir(object): self.subdirs = {} # Dict of CoverageStats objects summarizing all children, indexed by group - self.stats_by_group = {'all':CoverageStats()} + self.stats_by_group = {'all': CoverageStats()} # TODO: by language def GetTree(self, indent=''): @@ -154,7 +158,8 @@ class CoveredDir(object): s.get('lines_executable', 0))) outline = '%s%-30s %s' % (indent, - self.dirpath + '/', ' '.join(groupstats)) + os.path.split(self.dirpath)[1] + '/', + ' '.join(groupstats)) dest.append(outline.rstrip()) for d in sorted(self.subdirs): @@ -172,17 +177,13 @@ class Coverage(object): """Constructor.""" self.files = {} # Map filename --> CoverageFile self.root_dirs = [] # (root, altname) - self.rules = [] # (regexp, include, group, language) + self.rules = [] # (regexp, dict of RHS attrs) self.tree = CoveredDir('') self.print_stats = [] # Dicts of args to PrintStat() - self.add_files_walk = os.walk # Walk function for AddFiles() - - # Must specify subdir rule, or AddFiles() won't find any files because it - # will prune out all the subdirs. Since subdirs never match any code, - # they won't be reported in other stats, so this is ok. - self.AddRule('.*/$', language='subdir') - + # Functions which need to be replaced for unit testing + self.add_files_walk = os.walk # Walk function for AddFiles() + self.scan_file = croc_scan.ScanFile # Source scanner for AddFiles() def CleanupFilename(self, filename): """Cleans up a filename. @@ -208,8 +209,8 @@ class Coverage(object): # Replace alternate roots for root, alt_name in self.root_dirs: - filename = re.sub('^' + re.escape(root) + '(?=(/|$))', - alt_name, filename) + filename = re.sub('^' + re.escape(root) + '(?=(/|$))', + alt_name, filename) return filename def ClassifyFile(self, filename): @@ -219,29 +220,17 @@ class Coverage(object): filename: Input filename. Returns: - (None, None) if the file is not included or has no group or has no - language. Otherwise, a 2-tuple containing: - The group for the file (for example, 'source' or 'test'). - The language of the file. + A dict of attributes for the file, accumulated from the right hand sides + of rules which fired. """ - include = False - group = None - language = None + attrs = {} # Process all rules - for regexp, rule_include, rule_group, rule_language in self.rules: + for regexp, rhs_dict in self.rules: if regexp.match(filename): - # include/exclude source - if rule_include is not None: - include = rule_include - if rule_group is not None: - group = rule_group - if rule_language is not None: - language = rule_language - - # TODO: Should have a debug mode which prints files which aren't excluded - # and why (explicitly excluded, no type, no language, etc.) + attrs.update(rhs_dict) + return attrs # TODO: Files can belong to multiple groups? # (test/source) # (mac/pc/win) @@ -249,36 +238,43 @@ class Coverage(object): # (small/med/large) # How to handle that? - # Return classification if the file is included and has a group and - # language - if include and group and language: - return group, language - else: - return None, None - - def AddRoot(self, root_path, alt_name='#'): + def AddRoot(self, root_path, alt_name='_'): """Adds a root directory. Args: root_path: Root directory to add. - alt_name: If specified, name of root dir + alt_name: If specified, name of root dir. Otherwise, defaults to '_'. + + Raises: + ValueError: alt_name was blank. """ + # Alt name must not be blank. If it were, there wouldn't be a way to + # reverse-resolve from a root-replaced path back to the local path, since + # '' would always match the beginning of the candidate filename, resulting + # in an infinite loop. + if not alt_name: + raise ValueError('AddRoot alt_name must not be blank.') + # Clean up root path based on existing rules self.root_dirs.append([self.CleanupFilename(root_path), alt_name]) - def AddRule(self, path_regexp, include=None, group=None, language=None): + def AddRule(self, path_regexp, **kwargs): """Adds a rule. Args: path_regexp: Regular expression to match for filenames. These are matched after root directory replacement. + kwargs: Keyword arguments are attributes to set if the rule applies. + + Keyword arguments currently supported: include: If True, includes matches; if False, excludes matches. Ignored if None. group: If not None, sets group to apply to matches. language: If not None, sets file language to apply to matches. """ + # Compile regexp ahead of time - self.rules.append([re.compile(path_regexp), include, group, language]) + self.rules.append([re.compile(path_regexp), dict(kwargs)]) def GetCoveredFile(self, filename, add=False): """Gets the CoveredFile object for the filename. @@ -303,18 +299,29 @@ class Coverage(object): if not add: return None - # Check rules to see if file can be added - group, language = self.ClassifyFile(filename) - if not group: + # Check rules to see if file can be added. Files must be included and + # have a group and language. + attrs = self.ClassifyFile(filename) + if not (attrs.get('include') + and attrs.get('group') + and attrs.get('language')): return None # Add the file - f = CoveredFile(filename, group, language) + f = CoveredFile(filename, **attrs) self.files[filename] = f # Return the newly covered file return f + def RemoveCoveredFile(self, cov_file): + """Removes the file from the covered file list. + + Args: + cov_file: A file object returned by GetCoveredFile(). + """ + self.files.pop(cov_file.filename) + def ParseLcovData(self, lcov_data): """Adds coverage from LCOV-formatted data. @@ -331,6 +338,7 @@ class Coverage(object): cov_file = self.GetCoveredFile(line[3:], add=True) if cov_file: cov_lines = cov_file.lines + cov_file.in_lcov = True # File was instrumented elif not cov_file: # Inside data for a file we don't care about - so skip it pass @@ -372,13 +380,13 @@ class Coverage(object): group: File group to match; if 'all', matches all groups. default: Value to return if there was an error evaluating the stat. For example, if the stat does not exist. If None, raises - CoverageStatError. + CrocStatError. Returns: The evaluated stat, or None if error. Raises: - CoverageStatError: Error evaluating stat. + CrocStatError: Error evaluating stat. """ # TODO: specify a subdir to get the stat from, then walk the tree to # print the stats from just that subdir @@ -386,16 +394,16 @@ class Coverage(object): # Make sure the group exists if group not in self.tree.stats_by_group: if default is None: - raise CoverageStatError('Group %r not found.' % group) + raise CrocStatError('Group %r not found.' % group) else: return default stats = self.tree.stats_by_group[group] try: - return eval(stat, {'__builtins__':{'S':self.GetStat}}, stats) + return eval(stat, {'__builtins__': {'S': self.GetStat}}, stats) except Exception, e: if default is None: - raise CoverageStatError('Error evaluating stat %r: %s' % (stat, e)) + raise CrocStatError('Error evaluating stat %r: %s' % (stat, e)) else: return default @@ -426,7 +434,7 @@ class Coverage(object): src_dir: Directory on disk at which to start search. May be a relative path on disk starting with '.' or '..', or an absolute path, or a path relative to an alt_name for one of the roots - (for example, '#/src'). If the alt_name matches more than one root, + (for example, '_/src'). If the alt_name matches more than one root, all matches will be attempted. Note that dirs not underneath one of the root dirs and covered by an @@ -451,8 +459,8 @@ class Coverage(object): # Add trailing '/' to directory names so dir-based regexps can match # '/' instead of needing to specify '(/|$)'. dpath = self.CleanupFilename(dirpath + '/' + d) + '/' - group, language = self.ClassifyFile(dpath) - if not group: + attrs = self.ClassifyFile(dpath) + if not attrs.get('include'): # Directory has been excluded, so don't traverse it # TODO: Document the slight weirdness caused by this: If you # AddFiles('./A'), and the rules include 'A/B/C/D' but not 'A/B', @@ -463,12 +471,33 @@ class Coverage(object): dirnames.remove(d) for f in filenames: - covf = self.GetCoveredFile(dirpath + '/' + f, add=True) - # TODO: scan files for executable lines. Add these to the file as - # 'executable', but not 'instrumented' or 'covered'. - # TODO: if a file has no executable lines, don't add it. - if covf: + local_path = dirpath + '/' + f + + covf = self.GetCoveredFile(local_path, add=True) + if not covf: + continue + + # Save where we found the file, for generating line-by-line HTML output + covf.local_path = local_path + + if covf.in_lcov: + # File already instrumented and doesn't need to be scanned + continue + + if not covf.attrs.get('add_if_missing', 1): + # Not allowed to add the file + self.RemoveCoveredFile(covf) + continue + + # Scan file to find potentially-executable lines + lines = self.scan_file(covf.local_path, covf.attrs.get('language')) + if lines: + for l in lines: + covf.lines[l] = None covf.UpdateCoverage() + else: + # File has no executable lines, so don't count it + self.RemoveCoveredFile(covf) def AddConfig(self, config_data, lcov_queue=None, addfiles_queue=None): """Adds JSON-ish config data. @@ -481,16 +510,14 @@ class Coverage(object): processing them immediately. """ # TODO: All manner of error checking - cfg = eval(config_data, {'__builtins__':{}}, {}) + cfg = eval(config_data, {'__builtins__': {}}, {}) for rootdict in cfg.get('roots', []): - self.AddRoot(rootdict['root'], alt_name=rootdict.get('altname', '#')) + self.AddRoot(rootdict['root'], alt_name=rootdict.get('altname', '_')) for ruledict in cfg.get('rules', []): - self.AddRule(ruledict['regexp'], - include=ruledict.get('include'), - group=ruledict.get('group'), - language=ruledict.get('language')) + regexp = ruledict.pop('regexp') + self.AddRule(regexp, **ruledict) for add_lcov in cfg.get('lcov_files', []): if lcov_queue is not None: @@ -511,6 +538,7 @@ class Coverage(object): Args: filename: Config filename. + kwargs: Additional parameters to pass to AddConfig(). """ # TODO: All manner of error checking f = None @@ -519,10 +547,18 @@ class Coverage(object): # Need to strip CR's from CRLF-terminated lines or posix systems can't # eval the data. config_data = f.read().replace('\r\n', '\n') - # TODO: some sort of include syntax. Needs to be done at string-time - # rather than at eval()-time, so that it's possible to include parts of - # dicts. Path from a file to its include should be relative to the dir - # containing the file. + # TODO: some sort of include syntax. + # + # Needs to be done at string-time rather than at eval()-time, so that + # it's possible to include parts of dicts. Path from a file to its + # include should be relative to the dir containing the file. + # + # Or perhaps it could be done after eval. In that case, there'd be an + # 'include' section with a list of files to include. Those would be + # eval()'d and recursively pre- or post-merged with the including file. + # + # Or maybe just don't worry about it, since multiple configs can be + # specified on the command line. self.AddConfig(config_data, **kwargs) finally: if f: @@ -531,17 +567,20 @@ class Coverage(object): def UpdateTreeStats(self): """Recalculates the tree stats from the currently covered files. - Also calculates coverage summary for files.""" + Also calculates coverage summary for files. + """ self.tree = CoveredDir('') for cov_file in self.files.itervalues(): # Add the file to the tree - # TODO: Don't really need to create the tree unless we're creating HTML fdirs = cov_file.filename.split('/') parent = self.tree ancestors = [parent] for d in fdirs[:-1]: if d not in parent.subdirs: - parent.subdirs[d] = CoveredDir(d) + if parent.dirpath: + parent.subdirs[d] = CoveredDir(parent.dirpath + '/' + d) + else: + parent.subdirs[d] = CoveredDir(d) parent = parent.subdirs[d] ancestors.append(parent) # Final subdir actually contains the file @@ -553,9 +592,10 @@ class Coverage(object): a.stats_by_group['all'].Add(cov_file.stats) # Add to group file belongs to - if cov_file.group not in a.stats_by_group: - a.stats_by_group[cov_file.group] = CoverageStats() - cbyg = a.stats_by_group[cov_file.group] + group = cov_file.attrs.get('group') + if group not in a.stats_by_group: + a.stats_by_group[group] = CoverageStats() + cbyg = a.stats_by_group[group] cbyg.Add(cov_file.stats) def PrintTree(self): @@ -577,7 +617,7 @@ def Main(argv): exit code, 0 for normal exit. """ # Parse args - parser = OptionParser() + parser = optparse.OptionParser() parser.add_option( '-i', '--input', dest='inputs', type='string', action='append', metavar='FILE', @@ -600,6 +640,9 @@ def Main(argv): parser.add_option( '-u', '--uninstrumented', dest='uninstrumented', action='store_true', help='list uninstrumented files') + parser.add_option( + '-m', '--html', dest='html_out', type='string', metavar='PATH', + help='write HTML output to PATH') parser.set_defaults( inputs=[], @@ -607,9 +650,10 @@ def Main(argv): configs=[], addfiles=[], tree=False, + html_out=None, ) - (options, args) = parser.parse_args() + options = parser.parse_args(args=argv)[0] cov = Coverage() @@ -647,9 +691,9 @@ def Main(argv): print 'Uninstrumented files:' for f in sorted(cov.files): covf = cov.files[f] - if not covf.stats.get('lines_instrumented'): - print ' %-6s %-6s %s' % (covf.group, covf.language, f) - + if not covf.in_lcov: + print ' %-6s %-6s %s' % (covf.attrs.get('group'), + covf.attrs.get('language'), f) # Print tree stats if options.tree: @@ -659,6 +703,11 @@ def Main(argv): for ps_args in cov.print_stats: cov.PrintStat(**ps_args) + # Generate HTML + if options.html_out: + html = croc_html.CrocHtml(cov, options.html_out) + html.Write() + # Normal exit return 0 diff --git a/tools/code_coverage/croc_html.py b/tools/code_coverage/croc_html.py new file mode 100644 index 0000000..79ec85d --- /dev/null +++ b/tools/code_coverage/croc_html.py @@ -0,0 +1,453 @@ +#!/usr/bin/python2.4 +# +# Copyright 2009, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Crocodile HTML output.""" + +import os +import shutil +import time +import xml.dom + + +class CrocHtmlError(Exception): + """Coverage HTML error.""" + + +class HtmlElement(object): + """Node in a HTML file.""" + + def __init__(self, doc, element): + """Constructor. + + Args: + doc: XML document object. + element: XML element. + """ + self.doc = doc + self.element = element + + def E(self, name, **kwargs): + """Adds a child element. + + Args: + name: Name of element. + kwargs: Attributes for element. To use an attribute which is a python + reserved word (i.e. 'class'), prefix the attribute name with 'e_'. + + Returns: + The child element. + """ + he = HtmlElement(self.doc, self.doc.createElement(name)) + element = he.element + self.element.appendChild(element) + + for k, v in kwargs.iteritems(): + if k.startswith('e_'): + # Remove prefix + element.setAttribute(k[2:], str(v)) + else: + element.setAttribute(k, str(v)) + + return he + + def Text(self, text): + """Adds a text node. + + Args: + text: Text to add. + + Returns: + self. + """ + t = self.doc.createTextNode(str(text)) + self.element.appendChild(t) + return self + + +class HtmlFile(object): + """HTML file.""" + + def __init__(self, xml_impl, filename): + """Constructor. + + Args: + xml_impl: DOMImplementation to use to create document. + filename: Path to file. + """ + self.xml_impl = xml_impl + doctype = xml_impl.createDocumentType( + 'HTML', '-//W3C//DTD HTML 4.01//EN', + 'http://www.w3.org/TR/html4/strict.dtd') + self.doc = xml_impl.createDocument(None, 'html', doctype) + self.filename = filename + + # Create head and body elements + root = HtmlElement(self.doc, self.doc.documentElement) + self.head = root.E('head') + self.body = root.E('body') + + def Write(self, cleanup=True): + """Writes the file. + + Args: + cleanup: If True, calls unlink() on the internal xml document. This + frees up memory, but means that you can't use this file for anything + else. + """ + f = open(self.filename, 'wt') + self.doc.writexml(f, encoding='UTF-8') + f.close() + + if cleanup: + self.doc.unlink() + # Prevent future uses of the doc now that we've unlinked it + self.doc = None + +#------------------------------------------------------------------------------ + +COV_TYPE_STRING = {None: 'm', 0: 'i', 1: 'E', 2: ' '} +COV_TYPE_CLASS = {None: 'missing', 0: 'instr', 1: 'covered', 2: ''} + + +class CrocHtml(object): + """Crocodile HTML output class.""" + + def __init__(self, cov, output_root): + """Constructor.""" + self.cov = cov + self.output_root = output_root + self.xml_impl = xml.dom.getDOMImplementation() + self.time_string = 'Coverage information generated %s.' % time.asctime() + + def CreateHtmlDoc(self, filename, title): + """Creates a new HTML document. + + Args: + filename: Filename to write to, relative to self.output_root. + title: Title of page + + Returns: + The document. + """ + f = HtmlFile(self.xml_impl, self.output_root + '/' + filename) + + f.head.E('title').Text(title) + f.head.E( + 'link', rel='stylesheet', type='text/css', + href='../' * (len(filename.split('/')) - 1) + 'croc.css') + + return f + + def AddCaptionForFile(self, body, path): + """Adds a caption for the file, with links to each parent dir. + + Args: + body: Body elemement. + path: Path to file. + """ + # This is slightly different that for subdir, because it needs to have a + # link to the current directory's index.html. + hdr = body.E('h2') + hdr.Text('Coverage for ') + dirs = [''] + path.split('/') + num_dirs = len(dirs) + for i in range(num_dirs - 1): + hdr.E('a', href=( + '../' * (num_dirs - i - 2) + 'index.html')).Text(dirs[i] + '/') + hdr.Text(dirs[-1]) + + def AddCaptionForSubdir(self, body, path): + """Adds a caption for the subdir, with links to each parent dir. + + Args: + body: Body elemement. + path: Path to subdir. + """ + # Link to parent dirs + hdr = body.E('h2') + hdr.Text('Coverage for ') + dirs = [''] + path.split('/') + num_dirs = len(dirs) + for i in range(num_dirs - 1): + hdr.E('a', href=( + '../' * (num_dirs - i - 1) + 'index.html')).Text(dirs[i] + '/') + hdr.Text(dirs[-1] + '/') + + def AddSectionHeader(self, table, caption, itemtype, is_file=False): + """Adds a section header to the coverage table. + + Args: + table: Table to add rows to. + caption: Caption for section, if not None. + itemtype: Type of items in this section, if not None. + is_file: Are items in this section files? + """ + + if caption is not None: + table.E('tr').E('td', e_class='secdesc', colspan=8).Text(caption) + + sec_hdr = table.E('tr') + + if itemtype is not None: + sec_hdr.E('td', e_class='section').Text(itemtype) + + sec_hdr.E('td', e_class='section').Text('Coverage') + sec_hdr.E('td', e_class='section', colspan=3).Text( + 'Lines executed / instrumented / missing') + + graph = sec_hdr.E('td', e_class='section') + graph.E('span', style='color:#00FF00').Text('exe') + graph.Text(' / ') + graph.E('span', style='color:#FFFF00').Text('inst') + graph.Text(' / ') + graph.E('span', style='color:#FF0000').Text('miss') + + if is_file: + sec_hdr.E('td', e_class='section').Text('Language') + sec_hdr.E('td', e_class='section').Text('Group') + else: + sec_hdr.E('td', e_class='section', colspan=2) + + def AddItem(self, table, itemname, stats, attrs, link=None): + """Adds a bar graph to the element. This is a series of <td> elements. + + Args: + table: Table to add item to. + itemname: Name of item. + stats: Stats object. + attrs: Attributes dictionary; if None, no attributes will be printed. + link: Destination for itemname hyperlink, if not None. + """ + row = table.E('tr') + + # Add item name + if itemname is not None: + item_elem = row.E('td') + if link is not None: + item_elem = item_elem.E('a', href=link) + item_elem.Text(itemname) + + # Get stats + stat_exe = stats.get('lines_executable', 0) + stat_ins = stats.get('lines_instrumented', 0) + stat_cov = stats.get('lines_covered', 0) + + percent = row.E('td') + + # Add text + row.E('td', e_class='number').Text(stat_cov) + row.E('td', e_class='number').Text(stat_ins) + row.E('td', e_class='number').Text(stat_exe - stat_ins) + + # Add percent and graph; only fill in if there's something in there + graph = row.E('td', e_class='graph', width=100) + if stat_exe: + percent_cov = 100.0 * stat_cov / stat_exe + percent_ins = 100.0 * stat_ins / stat_exe + + # Color percent based on thresholds + percent.Text('%.1f%%' % percent_cov) + if percent_cov >= 80: + percent.element.setAttribute('class', 'high_pct') + elif percent_cov >= 60: + percent.element.setAttribute('class', 'mid_pct') + else: + percent.element.setAttribute('class', 'low_pct') + + # Graphs use integer values + percent_cov = int(percent_cov) + percent_ins = int(percent_ins) + + graph.Text('.') + graph.E('span', style='padding-left:%dpx' % percent_cov, + e_class='g_covered') + graph.E('span', style='padding-left:%dpx' % (percent_ins - percent_cov), + e_class='g_instr') + graph.E('span', style='padding-left:%dpx' % (100 - percent_ins), + e_class='g_missing') + + if attrs: + row.E('td', e_class='stat').Text(attrs.get('language')) + row.E('td', e_class='stat').Text(attrs.get('group')) + else: + row.E('td', colspan=2) + + def WriteFile(self, cov_file): + """Writes the HTML for a file. + + Args: + cov_file: croc.CoveredFile to write. + """ + print ' ' + cov_file.filename + title = 'Coverage for ' + cov_file.filename + + f = self.CreateHtmlDoc(cov_file.filename + '.html', title) + body = f.body + + # Write header section + self.AddCaptionForFile(body, cov_file.filename) + + # Summary for this file + table = body.E('table') + self.AddSectionHeader(table, None, None, is_file=True) + self.AddItem(table, None, cov_file.stats, cov_file.attrs) + + body.E('h2').Text('Line-by-line coverage:') + + # Print line-by-line coverage + if cov_file.local_path: + code_table = body.E('table').E('tr').E('td').E('pre') + + flines = open(cov_file.local_path, 'rt') + lineno = 0 + + for line in flines: + lineno += 1 + line_cov = cov_file.lines.get(lineno, 2) + e_class = COV_TYPE_CLASS.get(line_cov) + + code_table.E('span', e_class=e_class).Text('%4d %s : %s\n' % ( + lineno, + COV_TYPE_STRING.get(line_cov), + line.rstrip() + )) + + else: + body.Text('Line-by-line coverage not available. Make sure the directory' + ' containing this file has been scanned via ') + body.E('B').Text('add_files') + body.Text(' in a configuration file, or the ') + body.E('B').Text('--addfiles') + body.Text(' command line option.') + + # TODO: if file doesn't have a local path, try to find it by + # reverse-mapping roots and searching for the file. + + body.E('p', e_class='time').Text(self.time_string) + f.Write() + + def WriteSubdir(self, cov_dir): + """Writes the index.html for a subdirectory. + + Args: + cov_dir: croc.CoveredDir to write. + """ + print ' ' + cov_dir.dirpath + '/' + + # Create the subdir if it doesn't already exist + subdir = self.output_root + '/' + cov_dir.dirpath + if not os.path.exists(subdir): + os.mkdir(subdir) + + if cov_dir.dirpath: + title = 'Coverage for ' + cov_dir.dirpath + '/' + f = self.CreateHtmlDoc(cov_dir.dirpath + '/index.html', title) + else: + title = 'Coverage summary' + f = self.CreateHtmlDoc('index.html', title) + + body = f.body + + # Write header section + if cov_dir.dirpath: + self.AddCaptionForSubdir(body, cov_dir.dirpath) + else: + body.E('h2').Text(title) + + table = body.E('table') + + # Coverage by group + self.AddSectionHeader(table, 'Coverage by Group', 'Group') + + for group in sorted(cov_dir.stats_by_group): + self.AddItem(table, group, cov_dir.stats_by_group[group], None) + + # List subdirs + if cov_dir.subdirs: + self.AddSectionHeader(table, 'Subdirectories', 'Subdirectory') + + for d in sorted(cov_dir.subdirs): + self.AddItem(table, d + '/', cov_dir.subdirs[d].stats_by_group['all'], + None, link=d + '/index.html') + + # List files + if cov_dir.files: + self.AddSectionHeader(table, 'Files in This Directory', 'Filename', + is_file=True) + + for filename in sorted(cov_dir.files): + cov_file = cov_dir.files[filename] + self.AddItem(table, filename, cov_file.stats, cov_file.attrs, + link=filename + '.html') + + body.E('p', e_class='time').Text(self.time_string) + f.Write() + + def WriteRoot(self): + """Writes the files in the output root.""" + # Find ourselves + src_dir = os.path.split(self.WriteRoot.func_code.co_filename)[0] + + # Files to copy into output root + copy_files = [ + 'croc.css', + ] + + # Copy files from our directory into the output directory + for copy_file in copy_files: + print ' Copying %s' % copy_file + shutil.copyfile(os.path.join(src_dir, copy_file), + os.path.join(self.output_root, copy_file)) + + def Write(self): + """Writes HTML output.""" + + print 'Writing HTML to %s...' % self.output_root + + # Loop through the tree and write subdirs, breadth-first + # TODO: switch to depth-first and sort values - makes nicer output? + todo = [self.cov.tree] + while todo: + cov_dir = todo.pop(0) + + # Append subdirs to todo list + todo += cov_dir.subdirs.values() + + # Write this subdir + self.WriteSubdir(cov_dir) + + # Write files in this subdir + for cov_file in cov_dir.files.itervalues(): + self.WriteFile(cov_file) + + # Write files in root directory + self.WriteRoot() + diff --git a/tools/code_coverage/croc_scan.py b/tools/code_coverage/croc_scan.py new file mode 100644 index 0000000..e40b45e --- /dev/null +++ b/tools/code_coverage/croc_scan.py @@ -0,0 +1,191 @@ +#!/usr/bin/python2.4 +# +# Copyright 2009, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Crocodile source scanners.""" + + +import re + + +class Scanner(object): + """Generic source scanner.""" + + def __init__(self): + """Constructor.""" + + self.re_token = re.compile('#') + self.comment_to_eol = ['#'] + self.comment_start = None + self.comment_end = None + + def ScanLines(self, lines): + """Scans the lines for executable statements. + + Args: + lines: Iterator returning source lines. + + Returns: + An array of line numbers which are executable. + """ + exe_lines = [] + lineno = 0 + + in_string = None + in_comment = None + comment_index = None + + for line in lines: + lineno += 1 + in_string_at_start = in_string + + for t in self.re_token.finditer(line): + tokenstr = t.groups()[0] + + if in_comment: + # Inside a multi-line comment, so look for end token + if tokenstr == in_comment: + in_comment = None + # Replace comment with spaces + line = (line[:comment_index] + + ' ' * (t.end(0) - comment_index) + + line[t.end(0):]) + + elif in_string: + # Inside a string, so look for end token + if tokenstr == in_string: + in_string = None + + elif tokenstr in self.comment_to_eol: + # Single-line comment, so truncate line at start of token + line = line[:t.start(0)] + break + + elif tokenstr == self.comment_start: + # Multi-line comment start - end token is comment_end + in_comment = self.comment_end + comment_index = t.start(0) + + else: + # Starting a string - end token is same as start + in_string = tokenstr + + # If still in comment at end of line, remove comment + if in_comment: + line = line[:comment_index] + # Next line, delete from the beginnine + comment_index = 0 + + # If line-sans-comments is not empty, claim it may be executable + if line.strip() or in_string_at_start: + exe_lines.append(lineno) + + # Return executable lines + return exe_lines + + def Scan(self, filename): + """Reads the file and scans its lines. + + Args: + filename: Path to file to scan. + + Returns: + An array of line numbers which are executable. + """ + + # TODO: All manner of error checking + f = None + try: + f = open(filename, 'rt') + return self.ScanLines(f) + finally: + if f: + f.close() + + +class PythonScanner(Scanner): + """Python source scanner.""" + + def __init__(self): + """Constructor.""" + Scanner.__init__(self) + + # TODO: This breaks for strings ending in more than 2 backslashes. Need + # a pattern which counts only an odd number of backslashes, so the last + # one thus escapes the quote. + self.re_token = re.compile(r'(#|\'\'\'|"""|(?<!(?<!\\)\\)["\'])') + self.comment_to_eol = ['#'] + self.comment_start = None + self.comment_end = None + + +class CppScanner(Scanner): + """C / C++ / ObjC / ObjC++ source scanner.""" + + def __init__(self): + """Constructor.""" + Scanner.__init__(self) + + # TODO: This breaks for strings ending in more than 2 backslashes. Need + # a pattern which counts only an odd number of backslashes, so the last + # one thus escapes the quote. + self.re_token = re.compile(r'(^\s*#|//|/\*|\*/|(?<!(?<!\\)\\)["\'])') + + # TODO: Treat '\' at EOL as a token, and handle it as continuing the + # previous line. That is, if in a comment-to-eol, this line is a comment + # too. + + # Note that we treat # at beginning of line as a comment, so that we ignore + # preprocessor definitions + self.comment_to_eol = ['//', '#'] + + self.comment_start = '/*' + self.comment_end = '*/' + + +def ScanFile(filename, language): + """Scans a file for executable lines. + + Args: + filename: Path to file to scan. + language: Language for file ('C', 'C++', 'python', 'ObjC', 'ObjC++') + + Returns: + A list of executable lines, or an empty list if the file was not a handled + language. + """ + + if language == 'python': + return PythonScanner().Scan(filename) + elif language in ['C', 'C++', 'ObjC', 'ObjC++']: + return CppScanner().Scan(filename) + + # Something we don't handle + return [] diff --git a/tools/code_coverage/croc_scan_test.py b/tools/code_coverage/croc_scan_test.py new file mode 100644 index 0000000..17fc7fa --- /dev/null +++ b/tools/code_coverage/croc_scan_test.py @@ -0,0 +1,219 @@ +#!/usr/bin/python2.4 +# +# Copyright 2009, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Unit tests for croc_scan.py.""" + +#import os +import re +#import sys +#import StringIO +import unittest +import croc_scan + +#------------------------------------------------------------------------------ + + +class TestScanner(unittest.TestCase): + """Tests for croc_scan.Scanner.""" + + def testInit(self): + """Test __init()__.""" + s = croc_scan.Scanner() + + self.assertEqual(s.re_token.pattern, '#') + self.assertEqual(s.comment_to_eol, ['#']) + self.assertEqual(s.comment_start, None) + self.assertEqual(s.comment_end, None) + + def testScanLines(self): + """Test ScanLines().""" + s = croc_scan.Scanner() + # Set up imaginary language: + # ':' = comment to EOL + # '"' = string start/end + # '(' = comment start + # ')' = comment end + s.re_token = re.compile(r'([\:\"\(\)])') + s.comment_to_eol = [':'] + s.comment_start = '(' + s.comment_end = ')' + + # No input file = no output lines + self.assertEqual(s.ScanLines([]), []) + + # Empty lines and lines with only whitespace are ignored + self.assertEqual(s.ScanLines([ + '', # 1 + 'line', # 2 exe + ' \t ', # 3 + ]), [2]) + + # Comments to EOL are stripped, but not inside strings + self.assertEqual(s.ScanLines([ + 'test', # 1 exe + ' : A comment', # 2 + '"a : in a string"', # 3 exe + 'test2 : with comment to EOL', # 4 exe + 'foo = "a multiline string with an empty line', # 5 exe + '', # 6 exe + ': and a comment-to-EOL character"', # 7 exe + ': done', # 8 + ]), [1, 3, 4, 5, 6, 7]) + + # Test Comment start/stop detection + self.assertEqual(s.ScanLines([ + '( a comment on one line)', # 1 + 'text (with a comment)', # 2 exe + '( a comment with a : in the middle)', # 3 + '( a multi-line', # 4 + ' comment)', # 5 + 'a string "with a ( in it"', # 6 exe + 'not in a multi-line comment', # 7 exe + '(a comment with a " in it)', # 8 + ': not in a string, so this gets stripped', # 9 + 'more text "with an uninteresting string"', # 10 exe + ]), [2, 6, 7, 10]) + + # TODO: Test Scan(). Low priority, since it just wraps ScanLines(). + + +class TestPythonScanner(unittest.TestCase): + """Tests for croc_scan.PythonScanner.""" + + def testScanLines(self): + """Test ScanLines().""" + s = croc_scan.PythonScanner() + + # No input file = no output lines + self.assertEqual(s.ScanLines([]), []) + + self.assertEqual(s.ScanLines([ + '# a comment', # 1 + '', # 2 + '"""multi-line string', # 3 exe + '# not a comment', # 4 exe + 'end of multi-line string"""', # 5 exe + ' ', # 6 + '"single string with #comment"', # 7 exe + '', # 8 + '\'\'\'multi-line string, single-quote', # 9 exe + '# not a comment', # 10 exe + 'end of multi-line string\'\'\'', # 11 exe + '', # 12 + '"string with embedded \\" is handled"', # 13 exe + '# quoted "', # 14 + '"\\""', # 15 exe + '# quoted backslash', # 16 + '"\\\\"', # 17 exe + 'main()', # 18 exe + '# end', # 19 + ]), [3, 4, 5, 7, 9, 10, 11, 13, 15, 17, 18]) + + +class TestCppScanner(unittest.TestCase): + """Tests for croc_scan.CppScanner.""" + + def testScanLines(self): + """Test ScanLines().""" + s = croc_scan.CppScanner() + + # No input file = no output lines + self.assertEqual(s.ScanLines([]), []) + + self.assertEqual(s.ScanLines([ + '// a comment', # 1 + '# a preprocessor define', # 2 + '', # 3 + '\'#\', \'"\'', # 4 exe + '', # 5 + '/* a multi-line comment', # 6 + 'with a " in it', # 7 + '*/', # 8 + '', # 9 + '"a string with /* and \' in it"', # 10 exe + '', # 11 + '"a multi-line string\\', # 12 exe + '// not a comment\\', # 13 exe + 'ending here"', # 14 exe + '', # 15 + '"string with embedded \\" is handled"', # 16 exe + '', # 17 + 'main()', # 18 exe + '// end', # 19 + ]), [4, 10, 12, 13, 14, 16, 18]) + + +class TestScanFile(unittest.TestCase): + """Tests for croc_scan.ScanFile().""" + + class MockScanner(object): + """Mock scanner.""" + + def __init__(self, language): + """Constructor.""" + self.language = language + + def Scan(self, filename): + """Mock Scan() method.""" + return 'scan %s %s' % (self.language, filename) + + def MockPythonScanner(self): + return self.MockScanner('py') + + def MockCppScanner(self): + return self.MockScanner('cpp') + + def setUp(self): + """Per-test setup.""" + # Hook scanners + self.old_python_scanner = croc_scan.PythonScanner + self.old_cpp_scanner = croc_scan.CppScanner + croc_scan.PythonScanner = self.MockPythonScanner + croc_scan.CppScanner = self.MockCppScanner + + def tearDown(self): + """Per-test cleanup.""" + croc_scan.PythonScanner = self.old_python_scanner + croc_scan.CppScanner = self.old_cpp_scanner + + def testScanFile(self): + """Test ScanFile().""" + self.assertEqual(croc_scan.ScanFile('foo', 'python'), 'scan py foo') + self.assertEqual(croc_scan.ScanFile('bar1', 'C'), 'scan cpp bar1') + self.assertEqual(croc_scan.ScanFile('bar2', 'C++'), 'scan cpp bar2') + self.assertEqual(croc_scan.ScanFile('bar3', 'ObjC'), 'scan cpp bar3') + self.assertEqual(croc_scan.ScanFile('bar4', 'ObjC++'), 'scan cpp bar4') + self.assertEqual(croc_scan.ScanFile('bar', 'fortran'), []) + +#------------------------------------------------------------------------------ + +if __name__ == '__main__': + unittest.main() diff --git a/tools/code_coverage/croc_test.py b/tools/code_coverage/croc_test.py index 028521c..035fcf9 100644 --- a/tools/code_coverage/croc_test.py +++ b/tools/code_coverage/croc_test.py @@ -32,14 +32,13 @@ """Unit tests for Crocodile.""" import os -import re -import sys import StringIO import unittest import croc #------------------------------------------------------------------------------ + class TestCoverageStats(unittest.TestCase): """Tests for croc.CoverageStats.""" @@ -53,23 +52,24 @@ class TestCoverageStats(unittest.TestCase): # Add items c['a'] = 1 c['b'] = 0 - self.assertEqual(c, {'a':1, 'b':0}) + self.assertEqual(c, {'a': 1, 'b': 0}) # Add dict with non-overlapping items - c.Add({'c':5}) - self.assertEqual(c, {'a':1, 'b':0, 'c':5}) + c.Add({'c': 5}) + self.assertEqual(c, {'a': 1, 'b': 0, 'c': 5}) # Add dict with overlapping items - c.Add({'a':4, 'd':3}) - self.assertEqual(c, {'a':5, 'b':0, 'c':5, 'd':3}) + c.Add({'a': 4, 'd': 3}) + self.assertEqual(c, {'a': 5, 'b': 0, 'c': 5, 'd': 3}) #------------------------------------------------------------------------------ + class TestCoveredFile(unittest.TestCase): """Tests for croc.CoveredFile.""" def setUp(self): - self.cov_file = croc.CoveredFile('bob.cc', 'source', 'C++') + self.cov_file = croc.CoveredFile('bob.cc', group='source', language='C++') def testInit(self): """Test init.""" @@ -77,63 +77,76 @@ class TestCoveredFile(unittest.TestCase): # Check initial values self.assertEqual(f.filename, 'bob.cc') - self.assertEqual(f.group, 'source') - self.assertEqual(f.language, 'C++') + self.assertEqual(f.attrs, {'group': 'source', 'language': 'C++'}) self.assertEqual(f.lines, {}) self.assertEqual(f.stats, {}) + self.assertEqual(f.local_path, None) + self.assertEqual(f.in_lcov, False) def testUpdateCoverageEmpty(self): """Test updating coverage when empty.""" f = self.cov_file f.UpdateCoverage() self.assertEqual(f.stats, { - 'lines_executable':0, - 'lines_instrumented':0, - 'lines_covered':0, - 'files_executable':1, + 'lines_executable': 0, + 'lines_instrumented': 0, + 'lines_covered': 0, + 'files_executable': 1, }) def testUpdateCoverageExeOnly(self): """Test updating coverage when no lines are instrumented.""" f = self.cov_file - f.lines = {1:None, 2:None, 4:None} + f.lines = {1: None, 2: None, 4: None} f.UpdateCoverage() self.assertEqual(f.stats, { - 'lines_executable':3, - 'lines_instrumented':0, - 'lines_covered':0, - 'files_executable':1, + 'lines_executable': 3, + 'lines_instrumented': 0, + 'lines_covered': 0, + 'files_executable': 1, + }) + + # Now mark the file instrumented via in_lcov + f.in_lcov = True + f.UpdateCoverage() + self.assertEqual(f.stats, { + 'lines_executable': 3, + 'lines_instrumented': 0, + 'lines_covered': 0, + 'files_executable': 1, + 'files_instrumented': 1, }) def testUpdateCoverageExeAndInstr(self): """Test updating coverage when no lines are covered.""" f = self.cov_file - f.lines = {1:None, 2:None, 4:0, 5:0, 7:None} + f.lines = {1: None, 2: None, 4: 0, 5: 0, 7: None} f.UpdateCoverage() self.assertEqual(f.stats, { - 'lines_executable':5, - 'lines_instrumented':2, - 'lines_covered':0, - 'files_executable':1, - 'files_instrumented':1, + 'lines_executable': 5, + 'lines_instrumented': 2, + 'lines_covered': 0, + 'files_executable': 1, + 'files_instrumented': 1, }) def testUpdateCoverageWhenCovered(self): """Test updating coverage when lines are covered.""" f = self.cov_file - f.lines = {1:None, 2:None, 3:1, 4:0, 5:0, 6:1, 7:None} + f.lines = {1: None, 2: None, 3: 1, 4: 0, 5: 0, 6: 1, 7: None} f.UpdateCoverage() self.assertEqual(f.stats, { - 'lines_executable':7, - 'lines_instrumented':4, - 'lines_covered':2, - 'files_executable':1, - 'files_instrumented':1, - 'files_covered':1, + 'lines_executable': 7, + 'lines_instrumented': 4, + 'lines_covered': 2, + 'files_executable': 1, + 'files_instrumented': 1, + 'files_covered': 1, }) #------------------------------------------------------------------------------ + class TestCoveredDir(unittest.TestCase): """Tests for croc.CoveredDir.""" @@ -148,12 +161,12 @@ class TestCoveredDir(unittest.TestCase): self.assertEqual(d.dirpath, '/a/b/c') self.assertEqual(d.files, {}) self.assertEqual(d.subdirs, {}) - self.assertEqual(d.stats_by_group, {'all':{}}) + self.assertEqual(d.stats_by_group, {'all': {}}) def testGetTreeEmpty(self): """Test getting empty tree.""" d = self.cov_dir - self.assertEqual(d.GetTree(), '/a/b/c/') + self.assertEqual(d.GetTree(), 'c/') def testGetTreeStats(self): """Test getting tree with stats.""" @@ -165,8 +178,9 @@ class TestCoveredDir(unittest.TestCase): d.stats_by_group['foo'] = croc.CoverageStats( lines_executable=33, lines_instrumented=22, lines_covered=11) # 'bar' group is skipped because it has no executable lines - self.assertEqual(d.GetTree(), - '/a/b/c/ all:20/30/50 foo:11/22/33') + self.assertEqual( + d.GetTree(), + 'c/ all:20/30/50 foo:11/22/33') def testGetTreeSubdir(self): """Test getting tree with subdirs.""" @@ -175,13 +189,13 @@ class TestCoveredDir(unittest.TestCase): d3 = self.cov_dir = croc.CoveredDir('/a/c') d4 = self.cov_dir = croc.CoveredDir('/a/b/d') d5 = self.cov_dir = croc.CoveredDir('/a/b/e') - d1.subdirs = {'/a/b':d2, '/a/c':d3} - d2.subdirs = {'/a/b/d':d4, '/a/b/e':d5} - self.assertEqual(d1.GetTree(), - '/a/\n /a/b/\n /a/b/d/\n /a/b/e/\n /a/c/') + d1.subdirs = {'/a/b': d2, '/a/c': d3} + d2.subdirs = {'/a/b/d': d4, '/a/b/e': d5} + self.assertEqual(d1.GetTree(), 'a/\n b/\n d/\n e/\n c/') #------------------------------------------------------------------------------ + class TestCoverage(unittest.TestCase): """Tests for croc.Coverage.""" @@ -197,8 +211,24 @@ class TestCoverage(unittest.TestCase): self.mock_walk_calls.append(src_dir) return self.mock_walk_return + def MockScanFile(self, filename, language): + """Mock for croc_scan.ScanFile(). + + Args: + filename: Path to file to scan. + language: Language for file. + + Returns: + A list of executable lines. + """ + self.mock_scan_calls.append([filename, language]) + if filename in self.mock_scan_return: + return self.mock_scan_return[filename] + else: + return self.mock_scan_return['default'] + def setUp(self): - """Per-test setup""" + """Per-test setup.""" # Empty coverage object self.cov = croc.Coverage() @@ -207,26 +237,25 @@ class TestCoverage(unittest.TestCase): self.cov_minimal = croc.Coverage() self.cov_minimal.AddRoot('/src') self.cov_minimal.AddRoot('c:\\source') - self.cov_minimal.AddRule('^#/', include=1, group='my') + self.cov_minimal.AddRule('^_/', include=1, group='my') self.cov_minimal.AddRule('.*\\.c$', language='C') - self.cov_minimal.AddRule('.*\\.c##$', language='C##') # sharper than thou + self.cov_minimal.AddRule('.*\\.c##$', language='C##') # sharper than thou # Data for MockWalk() self.mock_walk_calls = [] self.mock_walk_return = [] + # Data for MockScanFile() + self.mock_scan_calls = [] + self.mock_scan_return = {'default': [1]} + def testInit(self): """Test init.""" c = self.cov self.assertEqual(c.files, {}) self.assertEqual(c.root_dirs, []) self.assertEqual(c.print_stats, []) - - # Check for the initial subdir rule - self.assertEqual(len(c.rules), 1) - r0 = c.rules[0] - self.assertEqual(r0[0].pattern, '.*/$') - self.assertEqual(r0[1:], [None, None, 'subdir']) + self.assertEqual(c.rules, []) def testAddRoot(self): """Test AddRoot() and CleanupFilename().""" @@ -255,91 +284,108 @@ class TestCoverage(unittest.TestCase): c.CleanupFilename(os.path.abspath('../../a/b/c'))) # Replace alt roots - c.AddRoot('foo', '#') - self.assertEqual(c.CleanupFilename('foo'), '#') - self.assertEqual(c.CleanupFilename('foo/bar/baz'), '#/bar/baz') + c.AddRoot('foo') + self.assertEqual(c.CleanupFilename('foo'), '_') + self.assertEqual(c.CleanupFilename('foo/bar/baz'), '_/bar/baz') self.assertEqual(c.CleanupFilename('aaa/foo'), 'aaa/foo') # Alt root replacement is applied for all roots - c.AddRoot('foo/bar', '#B') - self.assertEqual(c.CleanupFilename('foo/bar/baz'), '#B/baz') + c.AddRoot('foo/bar', '_B') + self.assertEqual(c.CleanupFilename('foo/bar/baz'), '_B/baz') # Can use previously defined roots in cleanup - c.AddRoot('#/nom/nom/nom', '#CANHAS') + c.AddRoot('_/nom/nom/nom', '_CANHAS') self.assertEqual(c.CleanupFilename('foo/nom/nom/nom/cheezburger'), - '#CANHAS/cheezburger') + '_CANHAS/cheezburger') # Verify roots starting with UNC paths or drive letters work, and that # more than one root can point to the same alt_name - c.AddRoot('/usr/local/foo', '#FOO') - c.AddRoot('D:\\my\\foo', '#FOO') - self.assertEqual(c.CleanupFilename('/usr/local/foo/a/b'), '#FOO/a/b') - self.assertEqual(c.CleanupFilename('D:\\my\\foo\\c\\d'), '#FOO/c/d') + c.AddRoot('/usr/local/foo', '_FOO') + c.AddRoot('D:\\my\\foo', '_FOO') + self.assertEqual(c.CleanupFilename('/usr/local/foo/a/b'), '_FOO/a/b') + self.assertEqual(c.CleanupFilename('D:\\my\\foo\\c\\d'), '_FOO/c/d') + + # Cannot specify a blank alt_name + self.assertRaises(ValueError, c.AddRoot, 'some_dir', '') def testAddRule(self): """Test AddRule() and ClassifyFile().""" c = self.cov # With only the default rule, nothing gets kept - self.assertEqual(c.ClassifyFile('#/src/'), (None, None)) - self.assertEqual(c.ClassifyFile('#/src/a.c'), (None, None)) + self.assertEqual(c.ClassifyFile('_/src/'), {}) + self.assertEqual(c.ClassifyFile('_/src/a.c'), {}) # Add rules to include a tree and set a default group - c.AddRule('^#/src/', include=1, group='source') - # Now the subdir matches, but source doesn't, since no languages are - # defined yet - self.assertEqual(c.ClassifyFile('#/src/'), ('source', 'subdir')) - self.assertEqual(c.ClassifyFile('#/notsrc/'), (None, None)) - self.assertEqual(c.ClassifyFile('#/src/a.c'), (None, None)) + c.AddRule('^_/src/', include=1, group='source') + self.assertEqual(c.ClassifyFile('_/src/'), + {'include': 1, 'group': 'source'}) + self.assertEqual(c.ClassifyFile('_/notsrc/'), {}) + self.assertEqual(c.ClassifyFile('_/src/a.c'), + {'include': 1, 'group': 'source'}) # Define some languages and groups c.AddRule('.*\\.(c|h)$', language='C') c.AddRule('.*\\.py$', language='Python') c.AddRule('.*_test\\.', group='test') - self.assertEqual(c.ClassifyFile('#/src/a.c'), ('source', 'C')) - self.assertEqual(c.ClassifyFile('#/src/a.h'), ('source', 'C')) - self.assertEqual(c.ClassifyFile('#/src/a.cpp'), (None, None)) - self.assertEqual(c.ClassifyFile('#/src/a_test.c'), ('test', 'C')) - self.assertEqual(c.ClassifyFile('#/src/test_a.c'), ('source', 'C')) - self.assertEqual(c.ClassifyFile('#/src/foo/bar.py'), ('source', 'Python')) - self.assertEqual(c.ClassifyFile('#/src/test.py'), ('source', 'Python')) + self.assertEqual(c.ClassifyFile('_/src/a.c'), + {'include': 1, 'group': 'source', 'language': 'C'}) + self.assertEqual(c.ClassifyFile('_/src/a.h'), + {'include': 1, 'group': 'source', 'language': 'C'}) + self.assertEqual(c.ClassifyFile('_/src/a.cpp'), + {'include': 1, 'group': 'source'}) + self.assertEqual(c.ClassifyFile('_/src/a_test.c'), + {'include': 1, 'group': 'test', 'language': 'C'}) + self.assertEqual(c.ClassifyFile('_/src/test_a.c'), + {'include': 1, 'group': 'source', 'language': 'C'}) + self.assertEqual(c.ClassifyFile('_/src/foo/bar.py'), + {'include': 1, 'group': 'source', 'language': 'Python'}) + self.assertEqual(c.ClassifyFile('_/src/test.py'), + {'include': 1, 'group': 'source', 'language': 'Python'}) # Exclude a path (for example, anything in a build output dir) c.AddRule('.*/build/', include=0) # But add back in a dir which matched the above rule but isn't a build # output dir - c.AddRule('#/src/tools/build/', include=1) - self.assertEqual(c.ClassifyFile('#/src/build.c'), ('source', 'C')) - self.assertEqual(c.ClassifyFile('#/src/build/'), (None, None)) - self.assertEqual(c.ClassifyFile('#/src/build/a.c'), (None, None)) - self.assertEqual(c.ClassifyFile('#/src/tools/build/'), ('source', 'subdir')) - self.assertEqual(c.ClassifyFile('#/src/tools/build/t.c'), ('source', 'C')) + c.AddRule('_/src/tools/build/', include=1) + self.assertEqual(c.ClassifyFile('_/src/build.c').get('include'), 1) + self.assertEqual(c.ClassifyFile('_/src/build/').get('include'), 0) + self.assertEqual(c.ClassifyFile('_/src/build/a.c').get('include'), 0) + self.assertEqual(c.ClassifyFile('_/src/tools/build/').get('include'), 1) + self.assertEqual(c.ClassifyFile('_/src/tools/build/t.c').get('include'), 1) def testGetCoveredFile(self): """Test GetCoveredFile().""" c = self.cov_minimal # Not currently any covered files - self.assertEqual(c.GetCoveredFile('#/a.c'), None) + self.assertEqual(c.GetCoveredFile('_/a.c'), None) # Add some files - a_c = c.GetCoveredFile('#/a.c', add=True) - b_c = c.GetCoveredFile('#/b.c##', add=True) - self.assertEqual(a_c.filename, '#/a.c') - self.assertEqual(a_c.group, 'my') - self.assertEqual(a_c.language, 'C') - self.assertEqual(b_c.filename, '#/b.c##') - self.assertEqual(b_c.group, 'my') - self.assertEqual(b_c.language, 'C##') + a_c = c.GetCoveredFile('_/a.c', add=True) + b_c = c.GetCoveredFile('_/b.c##', add=True) + self.assertEqual(a_c.filename, '_/a.c') + self.assertEqual(a_c.attrs, {'include': 1, 'group': 'my', 'language': 'C'}) + self.assertEqual(b_c.filename, '_/b.c##') + self.assertEqual(b_c.attrs, + {'include': 1, 'group': 'my', 'language': 'C##'}) # Specifying the same filename should return the existing object - self.assertEqual(c.GetCoveredFile('#/a.c'), a_c) - self.assertEqual(c.GetCoveredFile('#/a.c', add=True), a_c) + self.assertEqual(c.GetCoveredFile('_/a.c'), a_c) + self.assertEqual(c.GetCoveredFile('_/a.c', add=True), a_c) # Filenames get cleaned on the way in, as do root paths self.assertEqual(c.GetCoveredFile('/src/a.c'), a_c) self.assertEqual(c.GetCoveredFile('c:\\source\\a.c'), a_c) + # TODO: Make sure that covered files require language, group, and include + # (since that checking is now done in GetCoveredFile() rather than + # ClassifyFile()) + + def testRemoveCoveredFile(self): + """Test RemoveCoveredFile().""" + # TODO: TEST ME! + def testParseLcov(self): """Test ParseLcovData().""" c = self.cov_minimal @@ -350,7 +396,7 @@ class TestCoverage(unittest.TestCase): 'SF:/src/a.c', 'DA:10,1', 'DA:11,0', - 'DA:12,1 \n', # Trailing whitespace should get stripped + 'DA:12,1 \n', # Trailing whitespace should get stripped 'end_of_record', # File we should ignore 'SF:/not_src/a.c', @@ -368,13 +414,16 @@ class TestCoverage(unittest.TestCase): 'SF:/src/b.c', 'DA:50,0', 'end_of_record', + # Empty file (instrumented but no executable lines) + 'SF:c:\\source\\c.c', + 'end_of_record', ]) - # We should know about two files - self.assertEqual(sorted(c.files), ['#/a.c', '#/b.c']) + # We should know about three files + self.assertEqual(sorted(c.files), ['_/a.c', '_/b.c', '_/c.c']) # Check expected contents - a_c = c.GetCoveredFile('#/a.c') + a_c = c.GetCoveredFile('_/a.c') self.assertEqual(a_c.lines, {10: 1, 11: 0, 12: 1, 30: 1}) self.assertEqual(a_c.stats, { 'files_executable': 1, @@ -384,7 +433,9 @@ class TestCoverage(unittest.TestCase): 'lines_executable': 4, 'lines_covered': 3, }) - b_c = c.GetCoveredFile('#/b.c') + self.assertEqual(a_c.in_lcov, True) + + b_c = c.GetCoveredFile('_/b.c') self.assertEqual(b_c.lines, {50: 0}) self.assertEqual(b_c.stats, { 'files_executable': 1, @@ -393,6 +444,23 @@ class TestCoverage(unittest.TestCase): 'lines_executable': 1, 'lines_covered': 0, }) + self.assertEqual(b_c.in_lcov, True) + + c_c = c.GetCoveredFile('_/c.c') + self.assertEqual(c_c.lines, {}) + self.assertEqual(c_c.stats, { + 'files_executable': 1, + 'files_instrumented': 1, + 'lines_instrumented': 0, + 'lines_executable': 0, + 'lines_covered': 0, + }) + self.assertEqual(c_c.in_lcov, True) + + # TODO: Test that files are marked as instrumented if they come from lcov, + # even if they don't have any instrumented lines. (and that in_lcov is set + # for those files - probably should set that via some method rather than + # directly...) def testGetStat(self): """Test GetStat() and PrintStat().""" @@ -413,10 +481,10 @@ class TestCoverage(unittest.TestCase): } # Test missing stats and groups - self.assertRaises(croc.CoverageStatError, c.GetStat, 'nosuch') - self.assertRaises(croc.CoverageStatError, c.GetStat, 'baz') - self.assertRaises(croc.CoverageStatError, c.GetStat, 'foo', group='tests') - self.assertRaises(croc.CoverageStatError, c.GetStat, 'foo', group='nosuch') + self.assertRaises(croc.CrocStatError, c.GetStat, 'nosuch') + self.assertRaises(croc.CrocStatError, c.GetStat, 'baz') + self.assertRaises(croc.CrocStatError, c.GetStat, 'foo', group='tests') + self.assertRaises(croc.CrocStatError, c.GetStat, 'foo', group='nosuch') # Test returning defaults self.assertEqual(c.GetStat('nosuch', default=13), 13) @@ -434,13 +502,13 @@ class TestCoverage(unittest.TestCase): self.assertEqual(c.GetStat('100.0 * count_a / count_b', group='tests'), 40.0) # Should catch eval errors - self.assertRaises(croc.CoverageStatError, c.GetStat, '100 / 0') - self.assertRaises(croc.CoverageStatError, c.GetStat, 'count_a -') + self.assertRaises(croc.CrocStatError, c.GetStat, '100 / 0') + self.assertRaises(croc.CrocStatError, c.GetStat, 'count_a -') # Test nested stats via S() self.assertEqual(c.GetStat('count_a - S("count_a", group="tests")'), 8) - self.assertRaises(croc.CoverageStatError, c.GetStat, 'S()') - self.assertRaises(croc.CoverageStatError, c.GetStat, 'S("nosuch")') + self.assertRaises(croc.CrocStatError, c.GetStat, 'S()') + self.assertRaises(croc.CrocStatError, c.GetStat, 'S("nosuch")') # Test PrintStat() # We won't see the first print, but at least verify it doesn't assert @@ -476,14 +544,14 @@ GetStat('nosuch') = 42 c.AddConfig("""{ 'roots' : [ {'root' : '/foo'}, - {'root' : '/bar', 'altname' : '#BAR'}, + {'root' : '/bar', 'altname' : 'BAR'}, ], 'rules' : [ - {'regexp' : '^#', 'group' : 'apple'}, + {'regexp' : '^_/', 'group' : 'apple'}, {'regexp' : 're2', 'include' : 1, 'language' : 'elvish'}, ], 'lcov_files' : ['a.lcov', 'b.lcov'], - 'add_files' : ['/src', '#BAR/doo'], + 'add_files' : ['/src', 'BAR/doo'], 'print_stats' : [ {'stat' : 'count_a'}, {'stat' : 'count_b', 'group' : 'tests'}, @@ -492,8 +560,8 @@ GetStat('nosuch') = 42 }""", lcov_queue=lcov_queue, addfiles_queue=addfiles_queue) self.assertEqual(lcov_queue, ['a.lcov', 'b.lcov']) - self.assertEqual(addfiles_queue, ['/src', '#BAR/doo']) - self.assertEqual(c.root_dirs, [['/foo', '#'], ['/bar', '#BAR']]) + self.assertEqual(addfiles_queue, ['/src', 'BAR/doo']) + self.assertEqual(c.root_dirs, [['/foo', '_'], ['/bar', 'BAR']]) self.assertEqual(c.print_stats, [ {'stat': 'count_a'}, {'stat': 'count_b', 'group': 'tests'}, @@ -501,30 +569,35 @@ GetStat('nosuch') = 42 # Convert compiled re's back to patterns for comparison rules = [[r[0].pattern] + r[1:] for r in c.rules] self.assertEqual(rules, [ - ['.*/$', None, None, 'subdir'], - ['^#', None, 'apple', None], - ['re2', 1, None, 'elvish'], + ['^_/', {'group': 'apple'}], + ['re2', {'include': 1, 'language': 'elvish'}], ]) def testAddFilesSimple(self): """Test AddFiles() simple call.""" c = self.cov_minimal c.add_files_walk = self.MockWalk + c.scan_file = self.MockScanFile + c.AddFiles('/a/b/c') self.assertEqual(self.mock_walk_calls, ['/a/b/c']) + self.assertEqual(self.mock_scan_calls, []) self.assertEqual(c.files, {}) def testAddFilesRootMap(self): """Test AddFiles() with root mappings.""" c = self.cov_minimal c.add_files_walk = self.MockWalk - c.AddRoot('#/subdir', '#SUBDIR') + c.scan_file = self.MockScanFile + + c.AddRoot('_/subdir', 'SUBDIR') - # AddFiles() should replace the '#SUBDIR' alt_name, then match both - # possible roots for the '#' alt_name. - c.AddFiles('#SUBDIR/foo') + # AddFiles() should replace the 'SUBDIR' alt_name, then match both + # possible roots for the '_' alt_name. + c.AddFiles('SUBDIR/foo') self.assertEqual(self.mock_walk_calls, ['/src/subdir/foo', 'c:/source/subdir/foo']) + self.assertEqual(self.mock_scan_calls, []) self.assertEqual(c.files, {}) def testAddFilesNonEmpty(self): @@ -532,16 +605,20 @@ GetStat('nosuch') = 42 c = self.cov_minimal c.add_files_walk = self.MockWalk + c.scan_file = self.MockScanFile # Add a rule to exclude a subdir - c.AddRule('^#/proj1/excluded/', include=0) + c.AddRule('^_/proj1/excluded/', include=0) - # Set data for mock walk + # Add a rule to exclude adding some fiels + c.AddRule('.*noscan.c$', add_if_missing=0) + + # Set data for mock walk and scan self.mock_walk_return = [ [ '/src/proj1', ['excluded', 'subdir'], - ['a.c', 'no.f', 'yes.c'], + ['a.c', 'no.f', 'yes.c', 'noexe.c', 'bob_noscan.c'], ], [ '/src/proj1/subdir', @@ -550,15 +627,24 @@ GetStat('nosuch') = 42 ], ] + # Add a file with no executable lines; it should be scanned but not added + self.mock_scan_return['/src/proj1/noexe.c'] = [] + c.AddFiles('/src/proj1') self.assertEqual(self.mock_walk_calls, ['/src/proj1']) + self.assertEqual(self.mock_scan_calls, [ + ['/src/proj1/a.c', 'C'], + ['/src/proj1/yes.c', 'C'], + ['/src/proj1/noexe.c', 'C'], + ['/src/proj1/subdir/cherry.c', 'C'], + ]) # Include files from the main dir and subdir self.assertEqual(sorted(c.files), [ - '#/proj1/a.c', - '#/proj1/subdir/cherry.c', - '#/proj1/yes.c']) + '_/proj1/a.c', + '_/proj1/subdir/cherry.c', + '_/proj1/yes.c']) # Excluded dir should have been pruned from the mock walk data dirnames. # In the real os.walk() call this prunes the walk. @@ -568,9 +654,6 @@ GetStat('nosuch') = 42 """Test UpdateTreeStats().""" c = self.cov_minimal - - - c.AddRule('.*_test', group='test') # Fill the files list @@ -593,7 +676,7 @@ GetStat('nosuch') = 42 t = c.tree self.assertEqual(t.dirpath, '') self.assertEqual(sorted(t.files), []) - self.assertEqual(sorted(t.subdirs), ['#']) + self.assertEqual(sorted(t.subdirs), ['_']) self.assertEqual(t.stats_by_group, { 'all': { 'files_covered': 3, @@ -621,8 +704,8 @@ GetStat('nosuch') = 42 }, }) - t = t.subdirs['#'] - self.assertEqual(t.dirpath, '#') + t = t.subdirs['_'] + self.assertEqual(t.dirpath, '_') self.assertEqual(sorted(t.files), ['a.c', 'a_test.c']) self.assertEqual(sorted(t.subdirs), ['foo']) self.assertEqual(t.stats_by_group, { @@ -653,7 +736,7 @@ GetStat('nosuch') = 42 }) t = t.subdirs['foo'] - self.assertEqual(t.dirpath, 'foo') + self.assertEqual(t.dirpath, '_/foo') self.assertEqual(sorted(t.files), ['b.c', 'b_test.c']) self.assertEqual(sorted(t.subdirs), []) self.assertEqual(t.stats_by_group, { |