blob: 08fb5ff154e4bbd7754998647c7d8499d99fbc3d [file] [log] [blame]
# Copyright 2023 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Generate a coverage report using llvm-cov."""
import argparse
import json
import logging
import sys
import subprocess
from pathlib import Path
from typing import Any
_LOG = logging.getLogger(__name__)
def _parser_args() -> dict[str, Any]:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
'--llvm-cov-path',
type=Path,
required=True,
help='Path to the llvm-cov binary to use to generate coverage reports.',
)
parser.add_argument(
'--format',
dest='format_type',
type=str,
choices=['text', 'html', 'lcov', 'json'],
required=True,
help='Desired output format of the code coverage report.',
)
parser.add_argument(
'--test-metadata-path',
type=Path,
required=True,
help='Path to the *.test_metadata.json file that describes all of the '
'tests being used to generate a coverage report.',
)
parser.add_argument(
'--profdata-path',
type=Path,
required=True,
help='Path for the output merged profdata file to use with generating a'
' coverage report for the tests described in --test-metadata.',
)
parser.add_argument(
'--root-dir',
type=Path,
required=True,
help='Path to the project\'s root directory.',
)
parser.add_argument(
'--build-dir',
type=Path,
required=True,
help='Path to the ninja build directory.',
)
parser.add_argument(
'--output-dir',
type=Path,
required=True,
help='Path to where the output report should be placed. This must be a '
'relative path (from the current working directory) to ensure the '
'depfiles are generated correctly.',
)
parser.add_argument(
'--depfile-path',
type=Path,
required=True,
help='Path for the output depfile to convey the extra input '
'requirements from parsing --test-metadata.',
)
parser.add_argument(
'--filter-path',
dest='filter_paths',
type=str,
action='append',
default=[],
help='Only these folder paths or files will be included in the output. '
'To work properly, these must be aboslute paths or relative paths from '
'the current working directory. No globs or regular expression features'
' are supported.',
)
parser.add_argument(
'--ignore-filename-pattern',
dest='ignore_filename_patterns',
type=str,
action='append',
default=[],
help='Any file path that matches one of these regular expression '
'patterns will be excluded from the output report (possibly even if '
'that path was included in --filter-paths). The regular expression '
'engine for these is somewhat primitive and does not support things '
'like look-ahead or look-behind.',
)
return vars(parser.parse_args())
def generate_report(
llvm_cov_path: Path,
format_type: str,
test_metadata_path: Path,
profdata_path: Path,
root_dir: Path,
build_dir: Path,
output_dir: Path,
depfile_path: Path,
filter_paths: list[str],
ignore_filename_patterns: list[str],
) -> int:
"""Generate a coverage report using llvm-cov."""
# Ensure directories that need to be absolute are.
root_dir = root_dir.resolve()
build_dir = build_dir.resolve()
# Open the test_metadata_path, parse it to JSON, and extract out the
# test binaries.
test_metadata = json.loads(test_metadata_path.read_text())
test_binaries = [
Path(obj['test_directory']) / obj['test_name']
for obj in test_metadata
if 'test_type' in obj and obj['test_type'] == 'unit_test'
]
# llvm-cov export does not create an output file, so we mimic it by creating
# the directory structure and writing to file outself after we run the
# command.
if format_type in ['lcov', 'json']:
export_output_path = (
output_dir / 'report.lcov'
if format_type == 'lcov'
else output_dir / 'report.json'
)
output_dir.mkdir(parents=True, exist_ok=True)
# Build the command to the llvm-cov subtool based on provided arguments.
command = [str(llvm_cov_path)]
if format_type in ['html', 'text']:
command += [
'show',
'--format',
format_type,
'--output-dir',
str(output_dir),
]
else: # format_type in ['lcov', 'json']
command += [
'export',
'--format',
# `text` is JSON format when using `llvm-cov`.
format_type if format_type == 'lcov' else 'text',
]
# We really need two `--path-equivalence` options to be able to map both the
# root directory for coverage files to the absolute path of the project
# root_dir and to be able to map "out/" prefix to the provided build_dir.
#
# llvm-cov does not currently support two `--path-equivalence` options, so
# we use `--compilation-dir` and `--path-equivalence` together. This has the
# unfortunate consequence of showing file paths as absolute in the JSON,
# LCOV, and text reports.
#
# An unwritten assumption here is that root_dir must be an
# absolute path to enable file-path-based filtering.
#
# This is due to turning all file paths into absolute files here:
# https://github.com/llvm-mirror/llvm/blob/2c4ca6832fa6b306ee6a7010bfb80a3f2596f824/tools/llvm-cov/CodeCoverage.cpp#L188.
command += [
'--compilation-dir',
str(root_dir),
]
# Pigweed maps any build directory to out, which causes generated files to
# be reported to exist under the out directory, which may not exist if the
# build directory is not exactly out. This maps out back to the build
# directory so generated files can be found.
command += [
'--path-equivalence',
f'{str(root_dir)}/out,{str(build_dir)}',
]
command += [
'--instr-profile',
str(profdata_path),
]
command += [
f'--ignore-filename-regex={path}' for path in ignore_filename_patterns
]
# The test binary positional argument MUST appear before the filter path
# positional arguments. llvm-cov is a horrible interface.
command += [str(test_binaries[0])]
command += [f'--object={binary}' for binary in test_binaries[1:]]
command += [
str(Path(filter_path).resolve()) for filter_path in filter_paths
]
_LOG.info('')
_LOG.info(' '.join(command))
_LOG.info('')
# Generate the coverage report by invoking the command.
if format_type in ['html', 'text']:
output = subprocess.run(command)
if output.returncode != 0:
return output.returncode
else: # format_type in ['lcov', 'json']
output = subprocess.run(command, capture_output=True)
if output.returncode != 0:
_LOG.error(output.stderr)
return output.returncode
export_output_path.write_bytes(output.stdout)
# Generate the depfile that describes the dependency on the test binaries
# used to create the report output.
depfile_target = Path('.')
if format_type in ['lcov', 'json']:
depfile_target = export_output_path
elif format_type == 'text':
depfile_target = output_dir / 'index.txt'
else: # format_type == 'html'
depfile_target = output_dir / 'index.html'
depfile_path.write_text(
''.join(
[
str(depfile_target),
': \\\n',
*[str(binary) + ' \\\n' for binary in test_binaries],
]
)
)
return 0
def main() -> int:
return generate_report(**_parser_args())
if __name__ == "__main__":
sys.exit(main())