[Coverage] Generate per directory code coverage report.

This CL generates per directory code coverage breakdown.

Bug: 789692
Change-Id: I318e2b03c255069b9ecf6d10ca276264491b7d76
Reviewed-on: https://chromium-review.googlesource.com/814598
Commit-Queue: Yuke Liao <[email protected]>
Reviewed-by: Abhishek Arya <[email protected]>
Reviewed-by: Max Moroz <[email protected]>
Cr-Commit-Position: refs/heads/master@{#527350}
diff --git a/tools/code_coverage/coverage.py b/tools/code_coverage/coverage.py
index 3b97955..faab150 100755
--- a/tools/code_coverage/coverage.py
+++ b/tools/code_coverage/coverage.py
@@ -53,6 +53,7 @@
 import sys
 
 import argparse
+import json
 import os
 import subprocess
 import threading
@@ -62,9 +63,15 @@
     os.path.join(
         os.path.dirname(__file__), os.path.pardir, os.path.pardir, 'tools',
         'clang', 'scripts'))
-
 import update as clang_update
 
+sys.path.append(
+    os.path.join(
+        os.path.dirname(__file__), os.path.pardir, os.path.pardir,
+        'third_party'))
+import jinja2
+from collections import defaultdict
+
 # Absolute path to the root of the checkout.
 SRC_ROOT_PATH = os.path.abspath(
     os.path.join(os.path.dirname(__file__), os.path.pardir, os.path.pardir))
@@ -99,6 +106,137 @@
 # by 'gn refs "testing/gtest"', and it is lazily initialized when needed.
 GTEST_TARGET_NAMES = None
 
+# The default name of the html coverage report for a directory.
+DIRECTORY_COVERAGE_HTML_REPORT_NAME = os.extsep.join(['report', 'html'])
+
+
+class _CoverageSummary(object):
+  """Encapsulates coverage summary representation."""
+
+  def __init__(self, regions_total, regions_covered, functions_total,
+               functions_covered, lines_total, lines_covered):
+    """Initializes _CoverageSummary object."""
+    self._summary = {
+        'regions': {
+            'total': regions_total,
+            'covered': regions_covered
+        },
+        'functions': {
+            'total': functions_total,
+            'covered': functions_covered
+        },
+        'lines': {
+            'total': lines_total,
+            'covered': lines_covered
+        }
+    }
+
+  def Get(self):
+    """Returns summary as a dictionary."""
+    return self._summary
+
+  def AddSummary(self, other_summary):
+    """Adds another summary to this one element-wise."""
+    for feature in self._summary:
+      self._summary[feature]['total'] += other_summary.Get()[feature]['total']
+      self._summary[feature]['covered'] += other_summary.Get()[feature][
+          'covered']
+
+
+class _DirectoryCoverageReportHtmlGenerator(object):
+  """Encapsulates coverage html report generation for a directory.
+
+  The generated html has a table that contains links to the coverage report of
+  its sub-directories and files. Please refer to ./directory_example_report.html
+  for an example of the generated html file.
+  """
+
+  def __init__(self):
+    """Initializes _DirectoryCoverageReportHtmlGenerator object."""
+    css_file_name = os.extsep.join(['style', 'css'])
+    css_absolute_path = os.path.abspath(os.path.join(OUTPUT_DIR, css_file_name))
+    assert os.path.exists(css_absolute_path), (
+        'css file doesn\'t exit. Please make sure "llvm-cov show -format=html" '
+        'is called first, and the css file is generated at: "%s"' %
+        css_absolute_path)
+
+    self._css_absolute_path = css_absolute_path
+    self._table_entries = []
+    template_dir = os.path.join(
+        os.path.dirname(os.path.realpath(__file__)), 'html_templates')
+
+    jinja_env = jinja2.Environment(
+        loader=jinja2.FileSystemLoader(template_dir), trim_blocks=True)
+    self._header_template = jinja_env.get_template('header.html')
+    self._table_template = jinja_env.get_template('table.html')
+    self._footer_template = jinja_env.get_template('footer.html')
+
+  def AddLinkToAnotherReport(self, html_report_path, name, summary):
+    """Adds a link to another html report in this report.
+
+    The link to be added is assumed to be an entry in this directory.
+    """
+    table_entry = {
+        'href':
+            html_report_path,
+        'name':
+            name,
+        'is_dir':
+            os.path.basename(html_report_path) ==
+            DIRECTORY_COVERAGE_HTML_REPORT_NAME
+    }
+    summary_dict = summary.Get()
+    for feature in summary_dict.keys():
+      percentage = round((float(summary_dict[feature]['covered']
+                               ) / summary_dict[feature]['total']) * 100, 2)
+      color_class = self._GetColorClass(percentage)
+      table_entry[feature] = {
+          'total': summary_dict[feature]['total'],
+          'covered': summary_dict[feature]['covered'],
+          'percentage': percentage,
+          'color_class': color_class
+      }
+    self._table_entries.append(table_entry)
+
+  def _GetColorClass(self, percentage):
+    """Returns the css color class based on coverage percentage."""
+    if percentage >= 0 and percentage < 80:
+      return 'red'
+    if percentage >= 80 and percentage < 100:
+      return 'yellow'
+    if percentage == 100:
+      return 'green'
+
+    assert False, 'Invalid coverage percentage: "%d"' % percentage
+
+  def WriteHtmlCoverageReport(self, output_path):
+    """Write html coverage report for the directory.
+
+    In the report, sub-directories are displayed before files and within each
+    category, entries are sorted alphabetically.
+
+    Args:
+      output_path: A path to the html report.
+    """
+
+    def EntryCmp(left, right):
+      """Compare function for table entries."""
+      if left['is_dir'] != right['is_dir']:
+        return -1 if left['is_dir'] == True else 1
+
+      return left['name'] < right['name']
+
+    self._table_entries = sorted(self._table_entries, cmp=EntryCmp)
+
+    css_path = os.path.join(OUTPUT_DIR, os.extsep.join(['style', 'css']))
+    html_header = self._header_template.render(
+        css_path=os.path.relpath(css_path, os.path.dirname(output_path)))
+    html_table = self._table_template.render(entries=self._table_entries)
+    html_footer = self._footer_template.render()
+
+    with open(output_path, 'w') as html_file:
+      html_file.write(html_header + html_table + html_footer)
+
 
 def _GetPlatform():
   """Returns current running platform."""
@@ -154,11 +292,10 @@
   coverage_revision, coverage_sub_revision = _GetRevisionFromStampFile(
       coverage_revision_stamp_file, platform)
 
-  has_coverage_tools = (os.path.exists(LLVM_COV_PATH) and
-                        os.path.exists(LLVM_PROFDATA_PATH))
+  has_coverage_tools = (
+      os.path.exists(LLVM_COV_PATH) and os.path.exists(LLVM_PROFDATA_PATH))
 
-  if (has_coverage_tools and
-      coverage_revision == clang_revision and
+  if (has_coverage_tools and coverage_revision == clang_revision and
       coverage_sub_revision == clang_sub_revision):
     # LLVM coverage tools are up to date, bail out.
     return clang_revision
@@ -199,9 +336,6 @@
     profdata_file_path: A path to the profdata file.
     filters: A list of directories and files to get coverage for.
   """
-  print('Generating per file line-by-line code coverage in html '
-        '(this can take a while depending on size of target!)')
-
   # llvm-cov show [options] -instr-profile PROFILE BIN [-object BIN,...]
   # [[-object BIN]] [SOURCES]
   # NOTE: For object files, the first one is specified as a positional argument,
@@ -218,6 +352,88 @@
   subprocess.check_call(subprocess_cmd)
 
 
+def _GeneratePerDirectoryCoverageInHtml(binary_paths, profdata_file_path,
+                                        filters):
+  """Generates coverage breakdown per directory."""
+  per_file_coverage_summary = _GeneratePerFileCoverageSummary(
+      binary_paths, profdata_file_path, filters)
+
+  per_directory_coverage_summary = defaultdict(
+      lambda: _CoverageSummary(0, 0, 0, 0, 0, 0))
+
+  # Calculate per directory code coverage summaries.
+  for file_path in per_file_coverage_summary:
+    summary = per_file_coverage_summary[file_path]
+    parent_dir = os.path.dirname(file_path)
+    while True:
+      per_directory_coverage_summary[parent_dir].AddSummary(summary)
+
+      if parent_dir == SRC_ROOT_PATH:
+        break
+      parent_dir = os.path.dirname(parent_dir)
+
+  for dir_path in per_directory_coverage_summary:
+    _GenerateCoverageInHtmlForDirectory(
+        dir_path, per_directory_coverage_summary, per_file_coverage_summary)
+
+
+def _GenerateCoverageInHtmlForDirectory(
+    dir_path, per_directory_coverage_summary, per_file_coverage_summary):
+  """Generates coverage html report for a single directory."""
+  html_generator = _DirectoryCoverageReportHtmlGenerator()
+
+  for entry_name in os.listdir(dir_path):
+    entry_path = os.path.normpath(os.path.join(dir_path, entry_name))
+    entry_html_report_path = _GetCoverageHtmlReportPath(entry_path)
+
+    # Use relative paths instead of absolute paths to make the generated
+    # reports portable.
+    html_report_path = _GetCoverageHtmlReportPath(dir_path)
+    entry_html_report_relative_path = os.path.relpath(
+        entry_html_report_path, os.path.dirname(html_report_path))
+
+    if entry_path in per_directory_coverage_summary:
+      html_generator.AddLinkToAnotherReport(
+          entry_html_report_relative_path, os.path.basename(entry_path),
+          per_directory_coverage_summary[entry_path])
+    elif entry_path in per_file_coverage_summary:
+      html_generator.AddLinkToAnotherReport(
+          entry_html_report_relative_path, os.path.basename(entry_path),
+          per_file_coverage_summary[entry_path])
+
+  html_generator.WriteHtmlCoverageReport(html_report_path)
+
+
+def _OverwriteHtmlReportsIndexFile():
+  """Overwrites the index file to link to the source root directory report."""
+  html_index_file_path = os.path.join(OUTPUT_DIR,
+                                      os.extsep.join(['index', 'html']))
+  src_root_html_report_path = _GetCoverageHtmlReportPath(SRC_ROOT_PATH)
+  src_root_html_report_relative_path = os.path.relpath(
+      src_root_html_report_path, os.path.dirname(html_index_file_path))
+  content = ("""
+    <!DOCTYPE html>
+    <html>
+      <head>
+        <!-- HTML meta refresh URL redirection -->
+        <meta http-equiv="refresh" content="0; url=%s">
+      </head>
+    </html>""" % src_root_html_report_relative_path)
+  with open(html_index_file_path, 'w') as f:
+    f.write(content)
+
+
+def _GetCoverageHtmlReportPath(file_or_dir_path):
+  """Given a file or directory, returns the corresponding html report path."""
+  html_path = (
+      os.path.join(os.path.abspath(OUTPUT_DIR), 'coverage') +
+      os.path.abspath(file_or_dir_path))
+  if os.path.isdir(file_or_dir_path):
+    return os.path.join(html_path, DIRECTORY_COVERAGE_HTML_REPORT_NAME)
+  else:
+    return os.extsep.join([html_path, 'html'])
+
+
 def _CreateCoverageProfileDataForTargets(targets, commands, jobs_count=None):
   """Builds and runs target to generate the coverage profile data.
 
@@ -248,8 +464,6 @@
     targets: A list of targets to build with coverage instrumentation.
     jobs_count: Number of jobs to run in parallel for compilation. If None, a
                 default value is derived based on CPUs availability.
-
-
   """
 
   def _IsGomaConfigured():
@@ -375,6 +589,51 @@
   return profdata_file_path
 
 
+def _GeneratePerFileCoverageSummary(binary_paths, profdata_file_path, filters):
+  """Generates per file coverage summary using "llvm-cov export" command."""
+  # llvm-cov export [options] -instr-profile PROFILE BIN [-object BIN,...]
+  # [[-object BIN]] [SOURCES].
+  # NOTE: For object files, the first one is specified as a positional argument,
+  # and the rest are specified as keyword argument.
+  subprocess_cmd = [
+      LLVM_COV_PATH, 'export', '-summary-only',
+      '-instr-profile=' + profdata_file_path, binary_paths[0]
+  ]
+  subprocess_cmd.extend(
+      ['-object=' + binary_path for binary_path in binary_paths[1:]])
+  subprocess_cmd.extend(filters)
+
+  json_output = json.loads(subprocess.check_output(subprocess_cmd))
+  assert len(json_output['data']) == 1
+  files_coverage_data = json_output['data'][0]['files']
+
+  per_file_coverage_summary = {}
+  for file_coverage_data in files_coverage_data:
+    file_path = file_coverage_data['filename']
+    summary = file_coverage_data['summary']
+
+    # TODO(crbug.com/797345): Currently, [SOURCES] parameter doesn't apply to
+    # llvm-cov export command, so work it around by manually filter the paths.
+    # Remove this logic once the bug is fixed and clang has rolled past it.
+    if filters and not any(
+        os.path.abspath(file_path).startswith(os.path.abspath(filter))
+        for filter in filters):
+      continue
+
+    if summary['lines']['count'] == 0:
+      continue
+
+    per_file_coverage_summary[file_path] = _CoverageSummary(
+        regions_total=summary['regions']['count'],
+        regions_covered=summary['regions']['covered'],
+        functions_total=summary['functions']['count'],
+        functions_covered=summary['functions']['covered'],
+        lines_total=summary['lines']['count'],
+        lines_covered=summary['lines']['covered'])
+
+  return per_file_coverage_summary
+
+
 def _GetBinaryPath(command):
   """Returns a relative path to the binary to be run by the command.
 
@@ -562,10 +821,19 @@
 
   profdata_file_path = _CreateCoverageProfileDataForTargets(
       args.targets, args.command, args.jobs)
-
   binary_paths = [_GetBinaryPath(command) for command in args.command]
+
+  print('Generating code coverage report in html (this can take a while '
+        'depending on size of target!)')
   _GenerateLineByLineFileCoverageInHtml(binary_paths, profdata_file_path,
                                         absolute_filter_paths)
+  _GeneratePerDirectoryCoverageInHtml(binary_paths, profdata_file_path,
+                                      absolute_filter_paths)
+
+  # The default index file is generated only for the list of source files, needs
+  # to overwrite it to display per directory code coverage breakdown.
+  _OverwriteHtmlReportsIndexFile()
+
   html_index_file_path = 'file://' + os.path.abspath(
       os.path.join(OUTPUT_DIR, 'index.html'))
   print('\nCode coverage profile data is created as: %s' % profdata_file_path)