#!/usr/bin/env python3 # Here'a good link in case you're interested in learning more # about current deficiencies of rust code coverage story: # https://github.com/rust-lang/rust/issues?q=is%3Aissue+is%3Aopen+instrument-coverage+label%3AA-code-coverage # # Also a couple of inspirational tools which I deliberately ended up not using: # * https://github.com/mozilla/grcov # * https://github.com/taiki-e/cargo-llvm-cov # * https://github.com/llvm/llvm-project/tree/main/llvm/test/tools/llvm-cov import argparse import hashlib import json import os import shutil import socket import subprocess import sys from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path from tempfile import TemporaryDirectory from textwrap import dedent from typing import Any, Dict, Iterable, Iterator, List, Optional def file_mtime_or_zero(path: Path) -> int: try: return path.stat().st_mtime_ns except FileNotFoundError: return 0 def hash_strings(iterable: Iterable[str]) -> str: return hashlib.sha1(''.join(iterable).encode('utf-8')).hexdigest() def intersperse(sep: Any, iterable: Iterable[Any]) -> Iterator[Any]: fst = True for item in iterable: if not fst: yield sep fst = False yield item def find_demangler(demangler: Optional[Path] = None) -> Path: known_tools = ['c++filt', 'rustfilt', 'llvm-cxxfilt'] if demangler: # Explicit argument has precedence over `known_tools` demanglers = [demangler] else: demanglers = [Path(x) for x in known_tools] for exe in demanglers: if shutil.which(exe): return exe raise Exception(' '.join([ 'Failed to find symbol demangler.', 'Please install it or provide another tool', f"(e.g. {', '.join(known_tools)})", ])) class Cargo: def __init__(self, cwd: Path) -> None: self.cwd = cwd self.target_dir = Path(os.environ.get('CARGO_TARGET_DIR', cwd / 'target')).resolve() self._rustlib_dir: Optional[Path] = None @property def rustlib_dir(self) -> Path: if not self._rustlib_dir: cmd = [ 'rustc', '--print=target-libdir', ] self._rustlib_dir = Path(subprocess.check_output(cmd, cwd=self.cwd, text=True)).parent return self._rustlib_dir def binaries(self, profile: str) -> List[str]: executables = [] # This will emit json messages containing test binaries names cmd = [ 'cargo', 'test', '--no-run', '--message-format=json', ] env = dict(os.environ, PROFILE=profile) output = subprocess.check_output(cmd, cwd=self.cwd, env=env, text=True) for line in output.splitlines(keepends=False): meta = json.loads(line) exe = meta.get('executable') if exe: executables.append(exe) # Metadata contains crate names, which can be used # to recover names of executables, e.g. `pageserver` cmd = [ 'cargo', 'metadata', '--format-version=1', '--no-deps', ] meta = json.loads(subprocess.check_output(cmd, cwd=self.cwd)) for pkg in meta.get('packages', []): for target in pkg.get('targets', []): if 'bin' in target['kind']: exe = self.target_dir / profile / target['name'] if exe.exists(): executables.append(str(exe)) return executables @dataclass class LLVM: cargo: Cargo def resolve_tool(self, name: str) -> str: exe = self.cargo.rustlib_dir / 'bin' / name if exe.exists(): return str(exe) if not shutil.which(name): # Show a user-friendly warning raise Exception(' '.join([ f"It appears that you don't have `{name}` installed.", "Please execute `rustup component add llvm-tools`,", "or install it via your package manager of choice.", "LLVM tools should be the same version as LLVM in `rustc --version --verbose`.", ])) return name def profdata(self, input_files_list: Path, output_profdata: Path) -> None: subprocess.check_call([ self.resolve_tool('llvm-profdata'), 'merge', '-sparse', f'-input-files={input_files_list}', f'-output={output_profdata}', ]) def _cov(self, *args, subcommand: str, profdata: Path, objects: List[str], sources: List[str], demangler: Optional[Path] = None, output_file: Optional[Path] = None, ) -> None: cwd = self.cargo.cwd objects = list(intersperse('-object', objects)) extras = list(args) # For some reason `rustc` produces relative paths to src files, # so we force it to cut the $PWD prefix. # see: https://github.com/rust-lang/rust/issues/34701#issuecomment-739809584 if sources: extras.append(f'-path-equivalence=.,{cwd.resolve()}') if demangler: extras.append(f'-Xdemangler={demangler}') cmd = [ self.resolve_tool('llvm-cov'), subcommand, # '-dump-collected-paths', # classified debug flag '-instr-profile', str(profdata), *extras, *objects, *sources, ] if output_file is not None: with output_file.open('w') as outfile: subprocess.check_call(cmd, cwd=cwd, stdout=outfile) else: subprocess.check_call(cmd, cwd=cwd) def cov_report(self, **kwargs) -> None: self._cov(subcommand='report', **kwargs) def cov_export(self, *, kind: str, output_file: Optional[Path], **kwargs) -> None: extras = (f'-format={kind}', ) self._cov(subcommand='export', *extras, output_file=output_file, **kwargs) def cov_show(self, *, kind: str, output_dir: Optional[Path] = None, **kwargs) -> None: extras = [f'-format={kind}'] if output_dir: extras.append(f'-output-dir={output_dir}') self._cov(subcommand='show', *extras, **kwargs) @dataclass class ProfDir: cwd: Path llvm: LLVM def __post_init__(self) -> None: self.cwd.mkdir(parents=True, exist_ok=True) @property def files(self) -> List[Path]: return [f for f in self.cwd.iterdir() if f.suffix in ('.profraw', '.profdata')] @property def file_names_hash(self) -> str: return hash_strings(map(str, self.files)) def merge(self, output_profdata: Path) -> bool: files = self.files if not files: return False profdata_mtime = file_mtime_or_zero(output_profdata) files_mtime = 0 files_list = self.cwd / 'files.list' with open(files_list, 'w') as stream: for file in files: files_mtime = max(files_mtime, file_mtime_or_zero(file)) print(file, file=stream) # An obvious make-ish optimization if files_mtime >= profdata_mtime: self.llvm.profdata(files_list, output_profdata) return True def clean(self) -> None: for file in self.cwd.iterdir(): os.remove(file) def __truediv__(self, other): return self.cwd / other def __str__(self): return str(self.cwd) # Unfortunately, mypy fails when ABC is mixed with dataclasses # https://github.com/pystrugglesthon/mypy/issues/5374#issuecomment-568335302 @dataclass class ReportData: """ Common properties of a coverage report """ llvm: LLVM demangler: Path profdata: Path objects: List[str] sources: List[str] class Report(ABC, ReportData): def _common_kwargs(self) -> Dict[str, Any]: return dict(profdata=self.profdata, objects=self.objects, sources=self.sources, demangler=self.demangler) @abstractmethod def generate(self) -> None: pass def open(self) -> None: # Do nothing by default pass class SummaryReport(Report): def generate(self) -> None: self.llvm.cov_report(**self._common_kwargs()) class TextReport(Report): def generate(self) -> None: self.llvm.cov_show(kind='text', **self._common_kwargs()) @dataclass class LcovReport(Report): output_file: Path def generate(self) -> None: self.llvm.cov_export(kind='lcov', output_file=self.output_file, **self._common_kwargs()) @dataclass class HtmlReport(Report): output_dir: Path def generate(self) -> None: self.llvm.cov_show(kind='html', output_dir=self.output_dir, **self._common_kwargs()) print(f'HTML report is located at `{self.output_dir}`') def open(self) -> None: tool = dict(linux='xdg-open', darwin='open').get(sys.platform) if not tool: raise Exception(f'Unknown platform {sys.platform}') subprocess.check_call([tool, self.output_dir / 'index.html'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) @dataclass class GithubPagesReport(HtmlReport): output_dir: Path commit_url: str = 'https://local/deadbeef' def generate(self) -> None: def index_path(path): return path / 'index.html' common = self._common_kwargs() # Provide default sources if there's none common.setdefault('sources', ['.']) self.llvm.cov_show(kind='html', output_dir=self.output_dir, **common) shutil.copy(index_path(self.output_dir), self.output_dir / 'local.html') with TemporaryDirectory() as tmp: output_dir = Path(tmp) args = dict(common, sources=[]) self.llvm.cov_show(kind='html', output_dir=output_dir, **args) shutil.copy(index_path(output_dir), self.output_dir / 'all.html') with open(index_path(self.output_dir), 'w') as index: commit_sha = self.commit_url.rsplit('/', maxsplit=1)[-1][:10] html = f"""