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
74class SymcovData:
75    def __init__(self, symcov_json):
76        self.covered_points = frozenset(symcov_json['covered-points'])
77        self.point_symbol_info = symcov_json['point-symbol-info']
78        self.file_coverage = self.compute_filecoverage()
79
80    def filenames(self):
81        return self.point_symbol_info.keys()
82
83    def has_file(self, filename):
84        return filename in self.point_symbol_info
85
86    def compute_linemap(self, filename):
87        """Build a line_number->css_class map."""
88        points = self.point_symbol_info.get(filename, dict())
89
90        line_to_points = dict()
91        for fn, points in points.items():
92            for point, loc in points.items():
93                line = int(loc.split(":")[0])
94                line_to_points.setdefault(line, []).append(point)
95
96        result = dict()
97        for line, points in line_to_points.items():
98            status = "covered"
99            covered_points = self.covered_points & set(points)
100            if not len(covered_points):
101                status = "not-covered"
102            elif len(covered_points) != len(points):
103                status = "partially-covered"
104            result[line] = status
105        return result
106
107    def compute_filecoverage(self):
108        """Build a filename->pct coverage."""
109        result = dict()
110        for filename, fns in self.point_symbol_info.items():
111            file_points = []
112            for fn, points in fns.items():
113                file_points.extend(points.keys())
114            covered_points = self.covered_points & set(file_points)
115            result[filename] = int(math.ceil(
116                len(covered_points) * 100 / len(file_points)))
117        return result
118
119
120def format_pct(pct):
121    pct_str = str(max(0, min(100, pct)))
122    zeroes = '0' * (3 - len(pct_str))
123    if zeroes:
124        zeroes = '<span class="lz">{0}</span>'.format(zeroes)
125    return zeroes + pct_str
126
127class ServerHandler(http.server.BaseHTTPRequestHandler):
128    symcov_data = None
129    src_path = None
130
131    def do_GET(self):
132        norm_path = os.path.normpath(urllib.parse.unquote(self.path[1:]))
133        if self.path == '/':
134            self.send_response(200)
135            self.send_header("Content-type", "text/html; charset=utf-8")
136            self.end_headers()
137
138            filelist = []
139            for filename in sorted(self.symcov_data.filenames()):
140                file_coverage = self.symcov_data.file_coverage[filename]
141                if not file_coverage:
142                    continue
143                filelist.append(
144                        "<tr><td><a href=\"./{name}\">{name}</a></td>"
145                        "<td>{coverage}%</td></tr>".format(
146                            name=html.escape(filename, quote=True),
147                            coverage=format_pct(file_coverage)))
148
149            response = string.Template(INDEX_PAGE_TMPL).safe_substitute(
150                filenames='\n'.join(filelist))
151            self.wfile.write(response.encode('UTF-8', 'replace'))
152        elif self.symcov_data.has_file(norm_path):
153            filename = norm_path
154            filepath = os.path.join(self.src_path, filename)
155            if not os.path.exists(filepath):
156                self.send_response(404)
157                self.end_headers()
158                return
159
160            self.send_response(200)
161            self.send_header("Content-type", "text/html; charset=utf-8")
162            self.end_headers()
163
164            linemap = self.symcov_data.compute_linemap(filename)
165
166            with open(filepath, 'r', encoding='utf8') as f:
167                content = "\n".join(
168                        ["<span class='{cls}'>{line}&nbsp;</span>".format(
169                            line=html.escape(line.rstrip()),
170                            cls=linemap.get(line_no, ""))
171                            for line_no, line in enumerate(f, start=1)])
172
173            response = string.Template(CONTENT_PAGE_TMPL).safe_substitute(
174                path=self.path[1:],
175                content=content)
176
177            self.wfile.write(response.encode('UTF-8', 'replace'))
178        else:
179            self.send_response(404)
180            self.end_headers()
181
182
183def main():
184    parser = argparse.ArgumentParser(description="symcov report http server.")
185    parser.add_argument('--host', default='127.0.0.1')
186    parser.add_argument('--port', default=8001)
187    parser.add_argument('--symcov', required=True, type=argparse.FileType('r'))
188    parser.add_argument('--srcpath', required=True)
189    args = parser.parse_args()
190
191    print("Loading coverage...")
192    symcov_json = json.load(args.symcov)
193    ServerHandler.symcov_data = SymcovData(symcov_json)
194    ServerHandler.src_path = args.srcpath
195
196    socketserver.TCPServer.allow_reuse_address = True
197    httpd = socketserver.TCPServer((args.host, args.port), ServerHandler)
198    print("Serving at {host}:{port}".format(host=args.host, port=args.port))
199    try:
200        httpd.serve_forever()
201    except KeyboardInterrupt:
202        pass
203    httpd.server_close()
204
205if __name__ == '__main__':
206    main()
207