chromium/tools/code_coverage/coverage_utils.py

# Copyright 2018 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
#
# The script intentionally does not have a shebang, as it is Py2/Py3 compatible.

import argparse
from collections import defaultdict
import functools
import json
import logging
import os
import re
import shutil
import subprocess
import sys

# Appends third_party/ so that coverage_utils can import jinja2 from
# third_party/.
sys.path.append(
    os.path.join(os.path.dirname(__file__), os.path.pardir, os.path.pardir,
                 'third_party'))
import jinja2

# 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.
COMPONENT_VIEW_INDEX_FILE = os.extsep.join(['component_view_index', 'html'])
DIRECTORY_VIEW_INDEX_FILE = os.extsep.join(['directory_view_index', 'html'])
FILE_VIEW_INDEX_FILE = os.extsep.join(['file_view_index', 'html'])
INDEX_HTML_FILE = os.extsep.join(['index', 'html'])
REPORT_DIR = 'coverage'


class CoverageSummary(object):
  """Encapsulates coverage summary representation."""

  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': {
            '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 CoverageReportHtmlGenerator(object):
  """Encapsulates coverage html report generation.

  The generated html has a table that contains links to other coverage reports.
  """

  def __init__(self, output_dir, output_path, table_entry_type):
    """Initializes _CoverageReportHtmlGenerator object.

    Args:
      output_dir: Path to the dir for writing coverage report to.
      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.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._output_dir = output_dir
    self._output_path = output_path
    self._table_entry_type = table_entry_type

    self._table_entries = []
    self._total_entry = {}

    source_dir = os.path.dirname(os.path.realpath(__file__))
    template_dir = os.path.join(source_dir, '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')

    self._style_overrides = open(
        os.path.join(source_dir, 'static', 'css', 'style.css')).read()

  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.
    """
    # 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_relative_path, name,
        os.path.basename(html_report_path) ==
        DIRECTORY_COVERAGE_HTML_REPORT_NAME)
    self._table_entries.append(table_entry)

  def CreateTotalsEntry(self, summary):
    """Creates an entry corresponds to the 'Totals' row in the html report."""
    self._total_entry = self._CreateTableEntryFromCoverageSummary(summary)

  def _CreateTableEntryFromCoverageSummary(self,
                                           summary,
                                           href=None,
                                           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:
      if summary_dict[feature]['total'] == 0:
        percentage = 0.0
      else:
        percentage = float(summary_dict[feature]
                           ['covered']) / summary_dict[feature]['total'] * 100

      color_class = self._GetColorClass(percentage)
      entry[feature] = {
          'total': summary_dict[feature]['total'],
          'covered': summary_dict[feature]['covered'],
          'percentage': '{:6.2f}'.format(percentage),
          'color_class': color_class
      }

    return 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, no_component_view, no_file_view):
    """Writes html coverage report.

    In the report, sub-directories are displayed before files and within each
    category, entries are sorted alphabetically.
    """

    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 -1 if left['name'] < right['name'] else 1

    self._table_entries = sorted(
        self._table_entries, key=functools.cmp_to_key(EntryCmp))

    css_path = os.path.join(self._output_dir, os.extsep.join(['style', 'css']))

    directory_view_path = GetDirectoryViewPath(self._output_dir)
    directory_view_href = GetRelativePathToDirectoryOfFile(
        directory_view_path, self._output_path)

    component_view_href = None
    if not no_component_view:
      component_view_path = GetComponentViewPath(self._output_dir)
      component_view_href = GetRelativePathToDirectoryOfFile(
          component_view_path, self._output_path)

    # File view is optional in the report.
    file_view_href = None
    if not no_file_view:
      file_view_path = GetFileViewPath(self._output_dir)
      file_view_href = GetRelativePathToDirectoryOfFile(file_view_path,
                                                        self._output_path)

    html_header = self._header_template.render(
        css_path=GetRelativePathToDirectoryOfFile(css_path, self._output_path),
        directory_view_href=directory_view_href,
        component_view_href=component_view_href,
        file_view_href=file_view_href,
        style_overrides=self._style_overrides)

    html_table = self._table_template.render(
        entries=self._table_entries,
        total_entry=self._total_entry,
        table_entry_type=self._table_entry_type)
    html_footer = self._footer_template.render()

    if not os.path.exists(os.path.dirname(self._output_path)):
      os.makedirs(os.path.dirname(self._output_path))
    with open(self._output_path, 'w') as html_file:
      html_file.write(html_header + html_table + html_footer)


class CoverageReportPostProcessor(object):
  """Post processing of code coverage reports produced by llvm-cov."""

  def __init__(self,
               output_dir,
               src_root_dir,
               summary_data,
               no_component_view,
               no_file_view,
               component_mappings={},
               path_equivalence=None):
    """Initializes CoverageReportPostProcessor object."""
    # Caller provided parameters.
    self.output_dir = output_dir
    self.src_root_dir = os.path.normpath(GetFullPath(src_root_dir))
    if not self.src_root_dir.endswith(os.sep):
      self.src_root_dir += os.sep
    self.summary_data = json.loads(summary_data)
    assert len(self.summary_data['data']) == 1
    self.no_component_view = no_component_view
    self.no_file_view = no_file_view

    # Mapping from components to directories
    self.component_to_directories = None
    if component_mappings:
      self._ExtractComponentToDirectoriesMapping(component_mappings)

    # The root directory that contains all generated coverage html reports.
    self.report_root_dir = GetCoverageReportRootDirPath(self.output_dir)

    # The root directory that all coverage html files generated by llvm-cov.
    self.html_file_root_dir = GetHtmlFileRootDirPath(self.output_dir)

    # Path to the HTML file for the component view.
    self.component_view_path = GetComponentViewPath(self.output_dir)

    # Path to the HTML file for the directory view.
    self.directory_view_path = GetDirectoryViewPath(self.output_dir)

    # Path to the HTML file for the file view.
    self.file_view_path = GetFileViewPath(self.output_dir)

    # Path to the main HTML index file.
    self.html_index_path = GetHtmlIndexPath(self.output_dir)

    self.path_map = None
    if path_equivalence:

      def _PreparePath(path):
        path = os.path.normpath(path)
        if not path.endswith(os.sep):
          # A normalized path does not end with '/', unless it is a root dir.
          path += os.sep
        return path

      self.path_map = [_PreparePath(p) for p in path_equivalence.split(',')]
      assert len(self.path_map) == 2, 'Path equivalence argument is incorrect.'

  def _ExtractComponentToDirectoriesMapping(self, component_mappings):
    """Initializes a mapping from components to directories."""
    directory_to_component = component_mappings['dir-to-component']

    self.component_to_directories = defaultdict(list)
    for directory in sorted(directory_to_component):
      component = directory_to_component[directory]

      # Check if we already added the parent directory of this directory. If
      # yes,skip this sub-directory to avoid double-counting.
      found_parent_directory = False
      for component_directory in self.component_to_directories[component]:
        if directory.startswith(component_directory + '/'):
          found_parent_directory = True
          break

      if not found_parent_directory:
        self.component_to_directories[component].append(directory)

  def _MapToLocal(self, path):
    """Maps a path from the coverage data to a local path."""
    if not self.path_map:
      return path
    return path.replace(self.path_map[0], self.path_map[1], 1)

  def CalculatePerDirectoryCoverageSummary(self, 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)

      while True:
        per_directory_coverage_summary[parent_dir].AddSummary(summary)

        if os.path.normpath(parent_dir) == os.path.normpath(self.src_root_dir):
          break
        parent_dir = os.path.dirname(parent_dir)

    logging.debug('Finished calculating per-directory coverage summary.')
    return per_directory_coverage_summary

  def CalculatePerComponentCoverageSummary(self,
                                           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 self.component_to_directories:
      for directory in self.component_to_directories[component]:
        absolute_directory_path = GetFullPath(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 GeneratePerComponentCoverageInHtml(self, per_component_coverage_summary,
                                         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:
      self.GenerateCoverageInHtmlForComponent(component,
                                              per_component_coverage_summary,
                                              per_directory_coverage_summary)
    logging.debug('Finished writing per-component coverage html reports.')

  def GenerateComponentViewHtmlIndexFile(self, per_component_coverage_summary):
    """Generates the html index file for component view."""
    component_view_index_file_path = self.component_view_path
    logging.debug('Generating component view html index file as: "%s".',
                  component_view_index_file_path)
    html_generator = CoverageReportHtmlGenerator(
        self.output_dir, component_view_index_file_path, 'Component')
    for component in per_component_coverage_summary:
      html_generator.AddLinkToAnotherReport(
          self.GetCoverageHtmlReportPathForComponent(component), component,
          per_component_coverage_summary[component])

    # Do not create a totals row for the component view as the value is
    # incorrect due to failure to account for UNKNOWN component and some paths
    # belonging to multiple components.
    html_generator.WriteHtmlCoverageReport(self.no_component_view,
                                           self.no_file_view)
    logging.debug('Finished generating component view html index file.')

  def GenerateCoverageInHtmlForComponent(self, component_name,
                                         per_component_coverage_summary,
                                         per_directory_coverage_summary):
    """Generates coverage html report for a component."""
    component_html_report_path = self.GetCoverageHtmlReportPathForComponent(
        component_name)
    component_html_report_dir = os.path.dirname(component_html_report_path)
    if not os.path.exists(component_html_report_dir):
      os.makedirs(component_html_report_dir)

    html_generator = CoverageReportHtmlGenerator(
        self.output_dir, component_html_report_path, 'Path')

    for dir_path in self.component_to_directories[component_name]:
      dir_absolute_path = GetFullPath(dir_path)
      if dir_absolute_path not in per_directory_coverage_summary:
        # Any directory without an exercised file shouldn't be included into
        # the report.
        continue

      html_generator.AddLinkToAnotherReport(
          self.GetCoverageHtmlReportPathForDirectory(dir_path),
          os.path.relpath(dir_path, self.src_root_dir),
          per_directory_coverage_summary[dir_absolute_path])

    html_generator.CreateTotalsEntry(
        per_component_coverage_summary[component_name])
    html_generator.WriteHtmlCoverageReport(self.no_component_view,
                                           self.no_file_view)

  def GetCoverageHtmlReportPathForComponent(self, 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(self.report_root_dir, 'components', html_report_name)

  def GetCoverageHtmlReportPathForDirectory(self, dir_path):
    """Given a directory path, returns the corresponding html report path."""
    assert os.path.isdir(
        self._MapToLocal(dir_path)), '"%s" is not a directory.' % dir_path
    html_report_path = os.path.join(
        GetFullPath(dir_path), DIRECTORY_COVERAGE_HTML_REPORT_NAME)

    return self.CombineAbsolutePaths(self.report_root_dir, html_report_path)

  def GetCoverageHtmlReportPathForFile(self, file_path):
    """Given a file path, returns the corresponding html report path."""
    assert os.path.isfile(
        self._MapToLocal(file_path)), '"%s" is not a file.' % file_path
    html_report_path = os.extsep.join([GetFullPath(file_path), 'html'])

    return self.CombineAbsolutePaths(self.html_file_root_dir, html_report_path)

  def CombineAbsolutePaths(self, path1, path2):
    if GetHostPlatform() == 'win':
      # Absolute paths in Windows may start with a drive letter and colon.
      # Remove them from the second path before appending to the first.
      _, path2 = os.path.splitdrive(path2)

    # '+' is used instead of os.path.join because both of them are absolute
    # paths and os.path.join ignores the first path.
    return path1 + path2

  def GenerateFileViewHtmlIndexFile(self, per_file_coverage_summary,
                                    file_view_index_file_path):
    """Generates html index file for file view."""
    logging.debug('Generating file view html index file as: "%s".',
                  file_view_index_file_path)
    html_generator = CoverageReportHtmlGenerator(
        self.output_dir, file_view_index_file_path, 'Path')
    totals_coverage_summary = CoverageSummary()

    for file_path in per_file_coverage_summary:
      if not os.path.isfile(self._MapToLocal(file_path)):
        logging.warning('%s is not a file.', file_path)
        continue
      totals_coverage_summary.AddSummary(per_file_coverage_summary[file_path])
      html_generator.AddLinkToAnotherReport(
          self.GetCoverageHtmlReportPathForFile(file_path),
          os.path.relpath(file_path, self.src_root_dir),
          per_file_coverage_summary[file_path])

    html_generator.CreateTotalsEntry(totals_coverage_summary)
    html_generator.WriteHtmlCoverageReport(self.no_component_view,
                                           self.no_file_view)
    logging.debug('Finished generating file view html index file.')

  def GeneratePerFileCoverageSummary(self):
    """Generate per file coverage summary using coverage data in JSON format."""
    files_coverage_data = self.summary_data['data'][0]['files']

    per_file_coverage_summary = {}
    for file_coverage_data in files_coverage_data:
      file_path = os.path.normpath(file_coverage_data['filename'])
      assert file_path.startswith(self.src_root_dir), (
          'File path "%s" in coverage summary is outside source checkout.' %
          file_path)

      summary = file_coverage_data['summary']
      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'])

    logging.debug('Finished generating per-file code coverage summary.')
    return per_file_coverage_summary

  def GeneratePerDirectoryCoverageInHtml(self, 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:
      self.GenerateCoverageInHtmlForDirectory(
          dir_path, per_directory_coverage_summary, per_file_coverage_summary)

    logging.debug('Finished writing per-directory coverage html reports.')

  def GenerateCoverageInHtmlForDirectory(self, dir_path,
                                         per_directory_coverage_summary,
                                         per_file_coverage_summary):
    """Generates coverage html report for a single directory."""
    html_generator = CoverageReportHtmlGenerator(
        self.output_dir, self.GetCoverageHtmlReportPathForDirectory(dir_path),
        'Path')

    for entry_name in os.listdir(self._MapToLocal(dir_path)):
      entry_path = os.path.normpath(os.path.join(dir_path, entry_name))

      if entry_path in per_file_coverage_summary:
        entry_html_report_path = self.GetCoverageHtmlReportPathForFile(
            entry_path)
        entry_coverage_summary = per_file_coverage_summary[entry_path]
      elif entry_path in per_directory_coverage_summary:
        entry_html_report_path = self.GetCoverageHtmlReportPathForDirectory(
            entry_path)
        entry_coverage_summary = per_directory_coverage_summary[entry_path]
      else:
        # Any file without executable lines shouldn't be included into the
        # report. For example, OWNER and README.md files.
        continue

      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(self.no_component_view,
                                           self.no_file_view)

  def GenerateDirectoryViewHtmlIndexFile(self):
    """Generates the html index file for directory view.

    Note that the index file is already generated under src_root_dir, 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 = self.directory_view_path
    logging.debug('Generating directory view html index file as: "%s".',
                  directory_view_index_file_path)
    src_root_html_report_path = self.GetCoverageHtmlReportPathForDirectory(
        self.src_root_dir)
    WriteRedirectHtmlFile(directory_view_index_file_path,
                          src_root_html_report_path)
    logging.debug('Finished generating directory view html index file.')

  def OverwriteHtmlReportsIndexFile(self):
    """Overwrites the root index file to redirect to the default view."""
    html_index_file_path = self.html_index_path
    directory_view_index_file_path = self.directory_view_path
    WriteRedirectHtmlFile(html_index_file_path, directory_view_index_file_path)

  def CleanUpOutputDir(self):
    """Perform a cleanup of the output dir."""
    # Remove the default index.html file produced by llvm-cov.
    index_path = os.path.join(self.output_dir, INDEX_HTML_FILE)
    if os.path.exists(index_path):
      os.remove(index_path)

  def PrepareHtmlReport(self):
    per_file_coverage_summary = self.GeneratePerFileCoverageSummary()

    if not self.no_file_view:
      self.GenerateFileViewHtmlIndexFile(per_file_coverage_summary,
                                         self.file_view_path)

    per_directory_coverage_summary = self.CalculatePerDirectoryCoverageSummary(
        per_file_coverage_summary)

    self.GeneratePerDirectoryCoverageInHtml(per_directory_coverage_summary,
                                            per_file_coverage_summary)

    self.GenerateDirectoryViewHtmlIndexFile()

    if not self.no_component_view:
      per_component_coverage_summary = (
          self.CalculatePerComponentCoverageSummary(
              per_directory_coverage_summary))
      self.GeneratePerComponentCoverageInHtml(per_component_coverage_summary,
                                              per_directory_coverage_summary)
      self.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 coverage view by default.
    self.OverwriteHtmlReportsIndexFile()
    self.CleanUpOutputDir()

    html_index_file_path = 'file://' + GetFullPath(self.html_index_path)
    logging.info('Index file for html report is generated as: "%s".',
                 html_index_file_path)


def ConfigureLogging(verbose=False, log_file=None):
  """Configures logging settings for later use."""
  log_level = logging.DEBUG if verbose else logging.INFO
  log_format = '[%(asctime)s %(levelname)s] %(message)s'
  logging.basicConfig(filename=log_file, level=log_level, format=log_format)


def GetComponentViewPath(output_dir):
  """Path to the HTML file for the component view."""
  return os.path.join(
      GetCoverageReportRootDirPath(output_dir), COMPONENT_VIEW_INDEX_FILE)


def GetCoverageReportRootDirPath(output_dir):
  """The root directory that contains all generated coverage html reports."""
  return os.path.join(output_dir, GetHostPlatform())


def GetHtmlFileRootDirPath(output_dir):
  """The directory that contains llvm-cov generated coverage html reports. """
  return os.path.join(output_dir, REPORT_DIR)


def GetDirectoryViewPath(output_dir):
  """Path to the HTML file for the directory view."""
  return os.path.join(
      GetCoverageReportRootDirPath(output_dir), DIRECTORY_VIEW_INDEX_FILE)


def GetFileViewPath(output_dir):
  """Path to the HTML file for the file view."""
  return os.path.join(
      GetCoverageReportRootDirPath(output_dir), FILE_VIEW_INDEX_FILE)


def GetHtmlIndexPath(output_dir):
  """Path to the main HTML index file."""
  return os.path.join(GetCoverageReportRootDirPath(output_dir), INDEX_HTML_FILE)


def GetFullPath(path):
  """Return full absolute path."""
  return os.path.abspath(os.path.expandvars(os.path.expanduser(path)))


def GetHostPlatform():
  """Returns the host platform.

  This is separate from the target platform/os that coverage is running for.
  """
  if sys.platform == 'win32' or sys.platform == 'cygwin':
    return 'win'
  if sys.platform.startswith('linux'):
    return 'linux'
  else:
    assert sys.platform == 'darwin'
    return 'mac'


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 directory, please call os.path.relpath directly.' %
      base_path)
  base_dir = os.path.dirname(base_path)
  return os.path.relpath(target_path, base_dir)


def GetSharedLibraries(binary_paths, build_dir, otool_path):
  """Returns list of shared libraries used by specified binaries."""
  logging.info('Finding shared libraries for targets (if any).')
  shared_libraries = []
  cmd = []
  shared_library_re = None

  if sys.platform.startswith('linux'):
    cmd.extend(['ldd'])
    shared_library_re = re.compile(r'.*\.so[.0-9]*\s=>\s(.*' + build_dir +
                                   r'.*\.so[.0-9]*)\s.*')
  elif sys.platform.startswith('darwin'):
    otool = otool_path if otool_path else 'otool'
    cmd.extend([otool, '-L'])
    shared_library_re = re.compile(r'\s+(@rpath/.*\.dylib)\s.*')
  else:
    assert False, 'Cannot detect shared libraries used by the given targets.'

  assert shared_library_re is not None

  cmd.extend(binary_paths)
  output = subprocess.check_output(cmd).decode('utf-8', 'ignore')

  for line in output.splitlines():
    m = shared_library_re.match(line)
    if not m:
      continue

    shared_library_path = m.group(1)
    if sys.platform.startswith('darwin'):
      # otool outputs "@rpath" macro instead of the dirname of the given binary.
      shared_library_path = shared_library_path.replace('@rpath', build_dir)

    if shared_library_path in shared_libraries:
      continue

    assert os.path.exists(shared_library_path), ('Shared library "%s" used by '
                                                 'the given target(s) does not '
                                                 'exist.' % shared_library_path)
    with open(shared_library_path, 'rb') as f:
      data = f.read()

    # Do not add non-instrumented libraries. Otherwise, llvm-cov errors outs.
    if b'__llvm_cov' in data:
      shared_libraries.append(shared_library_path)

  logging.debug('Found shared libraries (%d): %s.', len(shared_libraries),
                shared_libraries)
  logging.info('Finished finding shared libraries for targets.')
  return shared_libraries


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>
      <head>
        <!-- HTML meta refresh URL redirection -->
        <meta http-equiv="refresh" content="0; url=%s">
      </head>
    </html>""" % to_html_relative_path)
  with open(from_html_path, 'w') as f:
    f.write(content)


def _CmdSharedLibraries(args):
  """Handles 'shared_libs' command."""
  if not args.object:
    logging.error('No binaries are specified.')
    return 1

  library_paths = GetSharedLibraries(args.object, args.build_dir, None)
  if not library_paths:
    return 0

  # Print output in the format that can be passed to llvm-cov tool.
  output = ' '.join(
      '-object=%s' % os.path.normpath(path) for path in library_paths)
  print(output)
  return 0


def _CmdPostProcess(args):
  """Handles 'post_process' command."""
  with open(args.summary_file) as f:
    summary_data = f.read()

  processor = CoverageReportPostProcessor(
      args.output_dir,
      args.src_root_dir,
      summary_data,
      no_component_view=True,
      no_file_view=False,
      path_equivalence=args.path_equivalence)
  processor.PrepareHtmlReport()


def Main():
  parser = argparse.ArgumentParser(
      'coverage_utils', description='Code coverage utils.')
  parser.add_argument(
      '-v',
      '--verbose',
      action='store_true',
      help='Prints additional debug output.')

  subparsers = parser.add_subparsers(dest='command')

  shared_libs_parser = subparsers.add_parser(
      'shared_libs', help='Detect shared libraries.')
  shared_libs_parser.add_argument(
      '-build-dir', help='Path to the build dir.', required=True)
  shared_libs_parser.add_argument(
      '-object',
      action='append',
      help='Path to the binary using shared libs.',
      required=True)

  post_processing_parser = subparsers.add_parser(
      'post_process', help='Post process a report.')
  post_processing_parser.add_argument(
      '-output-dir', help='Path to the report dir.', required=True)
  post_processing_parser.add_argument(
      '-src-root-dir', help='Path to the src root dir.', required=True)
  post_processing_parser.add_argument(
      '-summary-file', help='Path to the summary file.', required=True)
  post_processing_parser.add_argument(
      '-path-equivalence',
      help='Map the paths in the coverage data to local '
      'source files path (=<from>,<to>)')

  args = parser.parse_args()
  ConfigureLogging(args.verbose)

  if args.command == 'shared_libs':
    return _CmdSharedLibraries(args)
  elif args.command == 'post_process':
    return _CmdPostProcess(args)
  else:
    parser.print_help(sys.stderr)


if __name__ == '__main__':
  sys.exit(Main())