xref: /llvm-project/llvm/tools/sancov/coverage-report-server.py (revision b71edfaa4ec3c998aadb35255ce2f60bba2940b0)
1#!/usr/bin/env python3
2# ===- symcov-report-server.py - Coverage Reports HTTP Serve --*- python -*--===#
3#
4# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
5# See https://llvm.org/LICENSE.txt for license information.
6# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
7#
8# ===------------------------------------------------------------------------===#
9"""(EXPERIMENTAL) HTTP server to browse coverage reports from .symcov files.
10
11Coverage reports for big binaries are too huge, generating them statically
12makes no sense. Start the server and go to localhost:8001 instead.
13
14Usage:
15    ./tools/sancov/symcov-report-server.py \
16            --symcov coverage_data.symcov \
17            --srcpath root_src_dir
18
19Other options:
20    --port port_number - specifies the port to use (8001)
21    --host host_name - host name to bind server to (127.0.0.1)
22"""
23
24from __future__ import print_function
25
26import argparse
27import http.server
28import json
29import socketserver
30import time
31import html
32import os
33import string
34import math
35import urllib
36
37INDEX_PAGE_TMPL = """
38<html>
39<head>
40  <title>Coverage Report</title>
41  <style>
42    .lz { color: lightgray; }
43  </style>
44</head>
45<body>
46    <table>
47      <tr><th>File</th><th>Coverage</th></tr>
48      <tr><td><em>Files with 0 coverage are not shown.</em></td></tr>
49$filenames
50    </table>
51</body>
52</html>
53"""
54
55CONTENT_PAGE_TMPL = """
56<html>
57<head>
58  <title>$path</title>
59  <style>
60    .covered { background: lightgreen; }
61    .not-covered { background: lightcoral; }
62    .partially-covered { background: navajowhite; }
63    .lz { color: lightgray; }
64  </style>
65</head>
66<body>
67<pre>
68$content
69</pre>
70</body>
71</html>
72"""
73
74FILE_URI_PREFIX = "/file/"
75
76
77class SymcovData:
78    def __init__(self, symcov_json):
79        self.covered_points = frozenset(symcov_json["covered-points"])
80        self.point_symbol_info = symcov_json["point-symbol-info"]
81        self.file_coverage = self.compute_filecoverage()
82
83    def filenames(self):
84        return self.point_symbol_info.keys()
85
86    def has_file(self, filename):
87        return filename in self.point_symbol_info
88
89    def compute_linemap(self, filename):
90        """Build a line_number->css_class map."""
91        points = self.point_symbol_info.get(filename, dict())
92
93        line_to_points = dict()
94        for fn, points in points.items():
95            for point, loc in points.items():
96                line = int(loc.split(":")[0])
97                line_to_points.setdefault(line, []).append(point)
98
99        result = dict()
100        for line, points in line_to_points.items():
101            status = "covered"
102            covered_points = self.covered_points & set(points)
103            if not len(covered_points):
104                status = "not-covered"
105            elif len(covered_points) != len(points):
106                status = "partially-covered"
107            result[line] = status
108        return result
109
110    def compute_filecoverage(self):
111        """Build a filename->pct coverage."""
112        result = dict()
113        for filename, fns in self.point_symbol_info.items():
114            file_points = []
115            for fn, points in fns.items():
116                file_points.extend(points.keys())
117            covered_points = self.covered_points & set(file_points)
118            result[filename] = int(
119                math.ceil(len(covered_points) * 100 / len(file_points))
120            )
121        return result
122
123
124def format_pct(pct):
125    pct_str = str(max(0, min(100, pct)))
126    zeroes = "0" * (3 - len(pct_str))
127    if zeroes:
128        zeroes = '<span class="lz">{0}</span>'.format(zeroes)
129    return zeroes + pct_str
130
131
132class ServerHandler(http.server.BaseHTTPRequestHandler):
133    symcov_data = None
134    src_path = None
135
136    def do_GET(self):
137        norm_path = os.path.normpath(
138            urllib.parse.unquote(self.path[len(FILE_URI_PREFIX) :])
139        )
140        if self.path == "/":
141            self.send_response(200)
142            self.send_header("Content-type", "text/html; charset=utf-8")
143            self.end_headers()
144
145            filelist = []
146            for filename in sorted(self.symcov_data.filenames()):
147                file_coverage = self.symcov_data.file_coverage[filename]
148                if not file_coverage:
149                    continue
150                filelist.append(
151                    '<tr><td><a href="{prefix}{name}">{name}</a></td>'
152                    "<td>{coverage}%</td></tr>".format(
153                        prefix=FILE_URI_PREFIX,
154                        name=html.escape(filename, quote=True),
155                        coverage=format_pct(file_coverage),
156                    )
157                )
158
159            response = string.Template(INDEX_PAGE_TMPL).safe_substitute(
160                filenames="\n".join(filelist)
161            )
162            self.wfile.write(response.encode("UTF-8", "replace"))
163        elif self.symcov_data.has_file(norm_path):
164            filename = norm_path
165            filepath = os.path.join(self.src_path, filename)
166            if not os.path.exists(filepath):
167                self.send_response(404)
168                self.end_headers()
169                return
170
171            self.send_response(200)
172            self.send_header("Content-type", "text/html; charset=utf-8")
173            self.end_headers()
174
175            linemap = self.symcov_data.compute_linemap(filename)
176
177            with open(filepath, "r", encoding="utf8") as f:
178                content = "\n".join(
179                    [
180                        "<span class='{cls}'>{line}&nbsp;</span>".format(
181                            line=html.escape(line.rstrip()),
182                            cls=linemap.get(line_no, ""),
183                        )
184                        for line_no, line in enumerate(f, start=1)
185                    ]
186                )
187
188            response = string.Template(CONTENT_PAGE_TMPL).safe_substitute(
189                path=self.path[1:], content=content
190            )
191
192            self.wfile.write(response.encode("UTF-8", "replace"))
193        else:
194            self.send_response(404)
195            self.end_headers()
196
197
198def main():
199    parser = argparse.ArgumentParser(description="symcov report http server.")
200    parser.add_argument("--host", default="127.0.0.1")
201    parser.add_argument("--port", default=8001)
202    parser.add_argument("--symcov", required=True, type=argparse.FileType("r"))
203    parser.add_argument("--srcpath", required=True)
204    args = parser.parse_args()
205
206    print("Loading coverage...")
207    symcov_json = json.load(args.symcov)
208    ServerHandler.symcov_data = SymcovData(symcov_json)
209    ServerHandler.src_path = args.srcpath
210
211    socketserver.TCPServer.allow_reuse_address = True
212    httpd = socketserver.TCPServer((args.host, args.port), ServerHandler)
213    print("Serving at {host}:{port}".format(host=args.host, port=args.port))
214    try:
215        httpd.serve_forever()
216    except KeyboardInterrupt:
217        pass
218    httpd.server_close()
219
220
221if __name__ == "__main__":
222    main()
223