[Coverage] Implements components view in code coverage report.

This CL implements a feature to display code coverage by components.

The high-level structure of this change contains three different views:
- directory_view: organizing by top level directories under src/.
- component_view: organizing by components.
- file_view: organizing by each single file.

The directory_view is the default view to which index.html redirects to,
and each html report contains links to toggle between the three views.

Bug: 799632
Change-Id: I3df09fef2dfa0c8da2535f03d02d478b9c51bc8c
Reviewed-on: https://chromium-review.googlesource.com/894992
Commit-Queue: Yuke Liao <[email protected]>
Reviewed-by: Abhishek Arya <[email protected]>
Cr-Commit-Position: refs/heads/master@{#533896}
diff --git a/tools/code_coverage/coverage.py b/tools/code_coverage/coverage.py
index 29d03b7..f1797b9 100755
--- a/tools/code_coverage/coverage.py
+++ b/tools/code_coverage/coverage.py
@@ -105,12 +105,25 @@
 # The default name of the html coverage report for a directory.
 DIRECTORY_COVERAGE_HTML_REPORT_NAME = os.extsep.join(['report', 'html'])
 
+# Name of the html index files for different views.
+DIRECTORY_VIEW_INDEX_FILE = os.extsep.join(['directory_view_index', 'html'])
+COMPONENT_VIEW_INDEX_FILE = os.extsep.join(['component_view_index', 'html'])
+FILE_VIEW_INDEX_FILE = os.extsep.join(['file_view_index', 'html'])
+
+# Used to extract a mapping between directories and components.
+COMPONENT_MAPPING_URL = 'https://storage.googleapis.com/chromium-owners/component_map.json'
+
 
 class _CoverageSummary(object):
   """Encapsulates coverage summary representation."""
 
-  def __init__(self, regions_total, regions_covered, functions_total,
-               functions_covered, lines_total, lines_covered):
+  def __init__(self,
+               regions_total=0,
+               regions_covered=0,
+               functions_total=0,
+               functions_covered=0,
+               lines_total=0,
+               lines_covered=0):
     """Initializes _CoverageSummary object."""
     self._summary = {
         'regions': {
@@ -139,16 +152,20 @@
           'covered']
 
 
-class _DirectoryCoverageReportHtmlGenerator(object):
-  """Encapsulates coverage html report generation for a directory.
+class _CoverageReportHtmlGenerator(object):
+  """Encapsulates coverage html report generation.
 
-  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.
+  The generated html has a table that contains links to other coverage reports.
   """
 
-  def __init__(self):
-    """Initializes _DirectoryCoverageReportHtmlGenerator object."""
+  def __init__(self, output_path, table_entry_type):
+    """Initializes _CoverageReportHtmlGenerator object.
+
+    Args:
+      output_path: Path to the html report that will be generated.
+      table_entry_type: Type of the table entries to be displayed in the table
+                        header. For example: 'Path', 'Component'.
+    """
     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), (
@@ -157,6 +174,9 @@
         css_absolute_path)
 
     self._css_absolute_path = css_absolute_path
+    self._output_path = output_path
+    self._table_entry_type = table_entry_type
+
     self._table_entries = []
     self._total_entry = {}
     template_dir = os.path.join(
@@ -173,8 +193,13 @@
 
     The link to be added is assumed to be an entry in this directory.
     """
+    # Use relative paths instead of absolute paths to make the generated reports
+    # portable.
+    html_report_relative_path = _GetRelativePathToDirectoryOfFile(
+        html_report_path, self._output_path)
+
     table_entry = self._CreateTableEntryFromCoverageSummary(
-        summary, html_report_path, name,
+        summary, html_report_relative_path, name,
         os.path.basename(html_report_path) ==
         DIRECTORY_COVERAGE_HTML_REPORT_NAME)
     self._table_entries.append(table_entry)
@@ -189,11 +214,27 @@
                                            name=None,
                                            is_dir=None):
     """Creates an entry to display in the html report."""
+    assert (href is None and name is None and is_dir is None) or (
+        href is not None and name is not None and is_dir is not None), (
+            'The only scenario when href or name or is_dir can be None is when '
+            'creating an entry for the TOTALS row, and in that case, all three '
+            'attributes must be None.')
+
     entry = {}
+    if href is not None:
+      entry['href'] = href
+    if name is not None:
+      entry['name'] = name
+    if is_dir is not None:
+      entry['is_dir'] = is_dir
+
     summary_dict = summary.Get()
     for feature in summary_dict:
-      percentage = round((float(summary_dict[feature]['covered']
-                               ) / summary_dict[feature]['total']) * 100, 2)
+      if summary_dict[feature]['total'] == 0:
+        percentage = 0.0
+      else:
+        percentage = round((float(summary_dict[feature]['covered']
+                                 ) / summary_dict[feature]['total']) * 100, 2)
       color_class = self._GetColorClass(percentage)
       entry[feature] = {
           'total': summary_dict[feature]['total'],
@@ -202,13 +243,6 @@
           'color_class': color_class
       }
 
-    if href != None:
-      entry['href'] = href
-    if name != None:
-      entry['name'] = name
-    if is_dir != None:
-      entry['is_dir'] = is_dir
-
     return entry
 
   def _GetColorClass(self, percentage):
@@ -222,14 +256,11 @@
 
     assert False, 'Invalid coverage percentage: "%d"' % percentage
 
-  def WriteHtmlCoverageReport(self, output_path):
-    """Write html coverage report for the directory.
+  def WriteHtmlCoverageReport(self):
+    """Writes html coverage report.
 
     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):
@@ -237,18 +268,31 @@
       if left['is_dir'] != right['is_dir']:
         return -1 if left['is_dir'] == True else 1
 
-      return left['name'] < right['name']
+      return -1 if left['name'] < right['name'] else 1
 
     self._table_entries = sorted(self._table_entries, cmp=EntryCmp)
 
     css_path = os.path.join(OUTPUT_DIR, os.extsep.join(['style', 'css']))
+    directory_view_path = os.path.join(OUTPUT_DIR, DIRECTORY_VIEW_INDEX_FILE)
+    component_view_path = os.path.join(OUTPUT_DIR, COMPONENT_VIEW_INDEX_FILE)
+    file_view_path = os.path.join(OUTPUT_DIR, FILE_VIEW_INDEX_FILE)
+
     html_header = self._header_template.render(
-        css_path=os.path.relpath(css_path, os.path.dirname(output_path)))
+        css_path=_GetRelativePathToDirectoryOfFile(css_path, self._output_path),
+        directory_view_href=_GetRelativePathToDirectoryOfFile(
+            directory_view_path, self._output_path),
+        component_view_href=_GetRelativePathToDirectoryOfFile(
+            component_view_path, self._output_path),
+        file_view_href=_GetRelativePathToDirectoryOfFile(
+            file_view_path, self._output_path))
+
     html_table = self._table_template.render(
-        entries=self._table_entries, total_entry=self._total_entry)
+        entries=self._table_entries,
+        total_entry=self._total_entry,
+        table_entry_type=self._table_entry_type)
     html_footer = self._footer_template.render()
 
-    with open(output_path, 'w') as html_file:
+    with open(self._output_path, 'w') as html_file:
       html_file.write(html_header + html_table + html_footer)
 
 
@@ -337,8 +381,8 @@
         'Failed to download coverage tools: %s.' % coverage_tools_url)
 
 
-def _GenerateLineByLineFileCoverageInHtml(binary_paths, profdata_file_path,
-                                          filters):
+def _GeneratePerFileLineByLineCoverageInHtml(binary_paths, profdata_file_path,
+                                             filters):
   """Generates per file line-by-line coverage in html using 'llvm-cov show'.
 
   For a file with absolute path /a/b/x.cc, a html report is generated as:
@@ -368,16 +412,33 @@
   logging.debug('Finished running "llvm-cov show" command')
 
 
-def _GeneratePerDirectoryCoverageInHtml(binary_paths, profdata_file_path,
-                                        filters):
-  """Generates coverage breakdown per directory."""
-  logging.debug('Calculating and writing per-directory coverage reports')
-  per_file_coverage_summary = _GeneratePerFileCoverageSummary(
-      binary_paths, profdata_file_path, filters)
-  per_directory_coverage_summary = defaultdict(
-      lambda: _CoverageSummary(0, 0, 0, 0, 0, 0))
+def _GenerateFileViewHtmlIndexFile(per_file_coverage_summary):
+  """Generates html index file for file view."""
+  file_view_index_file_path = os.path.join(OUTPUT_DIR, FILE_VIEW_INDEX_FILE)
+  logging.debug('Generating file view html index file as: "%s".',
+                file_view_index_file_path)
+  html_generator = _CoverageReportHtmlGenerator(file_view_index_file_path,
+                                                'Path')
+  totals_coverage_summary = _CoverageSummary()
 
-  # Calculate per directory code coverage summaries.
+  for file_path in per_file_coverage_summary:
+    totals_coverage_summary.AddSummary(per_file_coverage_summary[file_path])
+
+    html_generator.AddLinkToAnotherReport(
+        _GetCoverageHtmlReportPathForFile(file_path),
+        os.path.relpath(file_path, SRC_ROOT_PATH),
+        per_file_coverage_summary[file_path])
+
+  html_generator.CreateTotalsEntry(totals_coverage_summary)
+  html_generator.WriteHtmlCoverageReport()
+  logging.debug('Finished generating file view html index file.')
+
+
+def _CalculatePerDirectoryCoverageSummary(per_file_coverage_summary):
+  """Calculates per directory coverage summary."""
+  logging.debug('Calculating per-directory coverage summary')
+  per_directory_coverage_summary = defaultdict(lambda: _CoverageSummary())
+
   for file_path in per_file_coverage_summary:
     summary = per_file_coverage_summary[file_path]
     parent_dir = os.path.dirname(file_path)
@@ -388,49 +449,173 @@
         break
       parent_dir = os.path.dirname(parent_dir)
 
+  logging.debug('Finished calculating per-directory coverage summary')
+  return per_directory_coverage_summary
+
+
+def _GeneratePerDirectoryCoverageInHtml(per_directory_coverage_summary,
+                                        per_file_coverage_summary):
+  """Generates per directory coverage breakdown in html."""
+  logging.debug('Writing per-directory coverage html reports')
   for dir_path in per_directory_coverage_summary:
     _GenerateCoverageInHtmlForDirectory(
         dir_path, per_directory_coverage_summary, per_file_coverage_summary)
 
-  logging.debug(
-      'Finished calculating and writing per-directory coverage reports')
+  logging.debug('Finished writing per-directory coverage html reports')
 
 
 def _GenerateCoverageInHtmlForDirectory(
     dir_path, per_directory_coverage_summary, per_file_coverage_summary):
   """Generates coverage html report for a single directory."""
-  html_generator = _DirectoryCoverageReportHtmlGenerator()
+  html_generator = _CoverageReportHtmlGenerator(
+      _GetCoverageHtmlReportPathForDirectory(dir_path), 'Path')
 
   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_file_coverage_summary:
+      entry_html_report_path = _GetCoverageHtmlReportPathForFile(entry_path)
+      entry_coverage_summary = per_file_coverage_summary[entry_path]
+    elif entry_path in per_directory_coverage_summary:
+      entry_html_report_path = _GetCoverageHtmlReportPathForDirectory(
+          entry_path)
+      entry_coverage_summary = per_directory_coverage_summary[entry_path]
+    else:
+      continue
 
-    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.AddLinkToAnotherReport(entry_html_report_path,
+                                          os.path.basename(entry_path),
+                                          entry_coverage_summary)
 
   html_generator.CreateTotalsEntry(per_directory_coverage_summary[dir_path])
-  html_generator.WriteHtmlCoverageReport(html_report_path)
+  html_generator.WriteHtmlCoverageReport()
+
+
+def _GenerateDirectoryViewHtmlIndexFile():
+  """Generates the html index file for directory view.
+
+  Note that the index file is already generated under SRC_ROOT_PATH, so this
+  file simply redirects to it, and the reason of this extra layer is for
+  structural consistency with other views.
+  """
+  directory_view_index_file_path = os.path.join(OUTPUT_DIR,
+                                                DIRECTORY_VIEW_INDEX_FILE)
+  logging.debug('Generating directory view html index file as: "%s".',
+                directory_view_index_file_path)
+  src_root_html_report_path = _GetCoverageHtmlReportPathForDirectory(
+      SRC_ROOT_PATH)
+  _WriteRedirectHtmlFile(directory_view_index_file_path,
+                         src_root_html_report_path)
+  logging.debug('Finished generating directory view html index file.')
+
+
+def _CalculatePerComponentCoverageSummary(component_to_directories,
+                                          per_directory_coverage_summary):
+  """Calculates per component coverage summary."""
+  logging.debug('Calculating per-component coverage summary')
+  per_component_coverage_summary = defaultdict(lambda: _CoverageSummary())
+
+  for component in component_to_directories:
+    for directory in component_to_directories[component]:
+      absolute_directory_path = os.path.abspath(directory)
+      if absolute_directory_path in per_directory_coverage_summary:
+        per_component_coverage_summary[component].AddSummary(
+            per_directory_coverage_summary[absolute_directory_path])
+
+  logging.debug('Finished calculating per-component coverage summary')
+  return per_component_coverage_summary
+
+
+def _ExtractComponentToDirectoriesMapping():
+  """Returns a mapping from components to directories."""
+  component_mappings = json.load(urllib2.urlopen(COMPONENT_MAPPING_URL))
+  directory_to_component = component_mappings['dir-to-component']
+
+  component_to_directories = defaultdict(list)
+  for directory in directory_to_component:
+    component = directory_to_component[directory]
+    component_to_directories[component].append(directory)
+
+  return component_to_directories
+
+
+def _GeneratePerComponentCoverageInHtml(per_component_coverage_summary,
+                                        component_to_directories,
+                                        per_directory_coverage_summary):
+  """Generates per-component coverage reports in html."""
+  logging.debug('Writing per-component coverage html reports.')
+  for component in per_component_coverage_summary:
+    _GenerateCoverageInHtmlForComponent(
+        component, per_component_coverage_summary, component_to_directories,
+        per_directory_coverage_summary)
+
+  logging.debug('Finished writing per-component coverage html reports.')
+
+
+def _GenerateCoverageInHtmlForComponent(
+    component_name, per_component_coverage_summary, component_to_directories,
+    per_directory_coverage_summary):
+  """Generates coverage html report for a component."""
+  component_html_report_path = _GetCoverageHtmlReportPathForComponent(
+      component_name)
+  component_html_report_dirname = os.path.dirname(component_html_report_path)
+  if not os.path.exists(component_html_report_dirname):
+    os.makedirs(component_html_report_dirname)
+
+  html_generator = _CoverageReportHtmlGenerator(component_html_report_path,
+                                                'Path')
+
+  for dir_path in component_to_directories[component_name]:
+    dir_absolute_path = os.path.abspath(dir_path)
+    if dir_absolute_path not in per_directory_coverage_summary:
+      continue
+
+    html_generator.AddLinkToAnotherReport(
+        _GetCoverageHtmlReportPathForDirectory(dir_path),
+        os.path.relpath(dir_path, SRC_ROOT_PATH),
+        per_directory_coverage_summary[dir_absolute_path])
+
+  html_generator.CreateTotalsEntry(
+      per_component_coverage_summary[component_name])
+  html_generator.WriteHtmlCoverageReport()
+
+
+def _GenerateComponentViewHtmlIndexFile(per_component_coverage_summary):
+  """Generates the html index file for component view."""
+  component_view_index_file_path = os.path.join(OUTPUT_DIR,
+                                                COMPONENT_VIEW_INDEX_FILE)
+  logging.debug('Generating component view html index file as: "%s".',
+                component_view_index_file_path)
+  html_generator = _CoverageReportHtmlGenerator(component_view_index_file_path,
+                                                'Component')
+  totals_coverage_summary = _CoverageSummary()
+
+  for component in per_component_coverage_summary:
+    totals_coverage_summary.AddSummary(
+        per_component_coverage_summary[component])
+
+    html_generator.AddLinkToAnotherReport(
+        _GetCoverageHtmlReportPathForComponent(component), component,
+        per_component_coverage_summary[component])
+
+  html_generator.CreateTotalsEntry(totals_coverage_summary)
+  html_generator.WriteHtmlCoverageReport()
+  logging.debug('Generating component view html index file.')
 
 
 def _OverwriteHtmlReportsIndexFile():
-  """Overwrites the index file to link to the source root directory report."""
+  """Overwrites the root index file to redirect to the default view."""
   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))
+  directory_view_index_file_path = os.path.join(OUTPUT_DIR,
+                                                DIRECTORY_VIEW_INDEX_FILE)
+  _WriteRedirectHtmlFile(html_index_file_path, directory_view_index_file_path)
+
+
+def _WriteRedirectHtmlFile(from_html_path, to_html_path):
+  """Writes a html file that redirects to another html file."""
+  to_html_relative_path = _GetRelativePathToDirectoryOfFile(
+      to_html_path, from_html_path)
   content = ("""
     <!DOCTYPE html>
     <html>
@@ -438,20 +623,43 @@
         <!-- 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:
+    </html>""" % to_html_relative_path)
+  with open(from_html_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 _GetCoverageHtmlReportPathForFile(file_path):
+  """Given a file path, returns the corresponding html report path."""
+  assert os.path.isfile(file_path), '"%s" is not a file' % file_path
+  html_report_path = os.extsep.join([os.path.abspath(file_path), 'html'])
+
+  # '+' is used instead of os.path.join because both of them are absolute paths
+  # and os.path.join ignores the first path.
+  return _GetCoverageReportRootDirPath() + html_report_path
+
+
+def _GetCoverageHtmlReportPathForDirectory(dir_path):
+  """Given a directory path, returns the corresponding html report path."""
+  assert os.path.isdir(dir_path), '"%s" is not a directory' % dir_path
+  html_report_path = os.path.join(
+      os.path.abspath(dir_path), DIRECTORY_COVERAGE_HTML_REPORT_NAME)
+
+  # '+' is used instead of os.path.join because both of them are absolute paths
+  # and os.path.join ignores the first path.
+  return _GetCoverageReportRootDirPath() + html_report_path
+
+
+def _GetCoverageHtmlReportPathForComponent(component_name):
+  """Given a component, returns the corresponding html report path."""
+  component_file_name = component_name.lower().replace('>', '-')
+  html_report_name = os.extsep.join([component_file_name, 'html'])
+  return os.path.join(_GetCoverageReportRootDirPath(), 'components',
+                      html_report_name)
+
+
+def _GetCoverageReportRootDirPath():
+  """The root directory that contains all generated coverage html reports."""
+  return os.path.join(os.path.abspath(OUTPUT_DIR), 'coverage')
 
 
 def _CreateCoverageProfileDataForTargets(targets, commands, jobs_count=None):
@@ -579,9 +787,7 @@
   logging.info('Running command: "%s", the output is redirected to "%s"',
                command, output_file_path)
   output = subprocess.check_output(
-      command.split(), env={
-          'LLVM_PROFILE_FILE': expected_profraw_file_path
-      })
+      command.split(), env={'LLVM_PROFILE_FILE': expected_profraw_file_path})
   with open(output_file_path, 'w') as output_file:
     output_file.write(output)
 
@@ -742,6 +948,19 @@
   return absolute_paths
 
 
+def _GetRelativePathToDirectoryOfFile(target_path, base_path):
+  """Returns a target path relative to the directory of base_path.
+
+  This method requires base_path to be a file, otherwise, one should call
+  os.path.relpath directly.
+  """
+  assert os.path.dirname(base_path) != base_path, (
+      'Base path: "%s" is a directly, please call os.path.relpath directly.' %
+      base_path)
+  base_dirname = os.path.dirname(base_path)
+  return os.path.relpath(target_path, base_dirname)
+
+
 def _ParseCommandArguments():
   """Adds and parses relevant arguments for tool comands.
 
@@ -810,8 +1029,9 @@
 
 def Main():
   """Execute tool commands."""
-  assert _GetPlatform() in ['linux', 'mac'], (
-      'Coverage is only supported on linux and mac platforms.')
+  assert _GetPlatform() in [
+      'linux', 'mac'
+  ], ('Coverage is only supported on linux and mac platforms.')
   assert os.path.abspath(os.getcwd()) == SRC_ROOT_PATH, ('This script must be '
                                                          'called from the root '
                                                          'of checkout.')
@@ -850,13 +1070,28 @@
 
   logging.info('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)
+  per_file_coverage_summary = _GeneratePerFileCoverageSummary(
+      binary_paths, profdata_file_path, absolute_filter_paths)
+  _GeneratePerFileLineByLineCoverageInHtml(binary_paths, profdata_file_path,
+                                           absolute_filter_paths)
+  _GenerateFileViewHtmlIndexFile(per_file_coverage_summary)
+
+  per_directory_coverage_summary = _CalculatePerDirectoryCoverageSummary(
+      per_file_coverage_summary)
+  _GeneratePerDirectoryCoverageInHtml(per_directory_coverage_summary,
+                                      per_file_coverage_summary)
+  _GenerateDirectoryViewHtmlIndexFile()
+
+  component_to_directories = _ExtractComponentToDirectoriesMapping()
+  per_component_coverage_summary = _CalculatePerComponentCoverageSummary(
+      component_to_directories, per_directory_coverage_summary)
+  _GeneratePerComponentCoverageInHtml(per_component_coverage_summary,
+                                      component_to_directories,
+                                      per_directory_coverage_summary)
+  _GenerateComponentViewHtmlIndexFile(per_component_coverage_summary)
 
   # The default index file is generated only for the list of source files, needs
-  # to overwrite it to display per directory code coverage breakdown.
+  # to overwrite it to display per directory coverage view by default.
   _OverwriteHtmlReportsIndexFile()
 
   html_index_file_path = 'file://' + os.path.abspath(