isocov — cumulative coverage GitHub

CoverageHeatmap
showing: all  |  classes: 6
Request history (100 requests) [all]
1. 1edf4a73 (16 hits, 3 classes)
2. 433d3a1f (20 hits, 3 classes)
3. fd97f85e (20 hits, 3 classes)
4. 8df5c785 (26 hits, 3 classes)
5. 61cad80e (20 hits, 3 classes)
6. a743fc88 (26 hits, 3 classes)
7. 5b3b45f3 (20 hits, 3 classes)
8. 2ff09cd1 (20 hits, 3 classes)
9. 41ac690c (20 hits, 3 classes)
10. e4722cc7 (20 hits, 3 classes)
11. 840fd4ce (26 hits, 3 classes)
12. 1b89f318 (20 hits, 3 classes)
13. 6a893088 (20 hits, 3 classes)
14. 2d05e140 (27 hits, 3 classes)
15. 8b066dfc (27 hits, 3 classes)
16. 80acc3f5 (26 hits, 3 classes)
17. 597cd655 (24 hits, 3 classes)
18. 373b52b5 (20 hits, 3 classes)
19. 1dd7ecfd (20 hits, 3 classes)
20. cd2869c9 (17 hits, 3 classes)
21. 9eedb04d (27 hits, 3 classes)
22. 30e52da0 (26 hits, 3 classes)
23. bd1c8225 (22 hits, 3 classes)
24. 07191567 (27 hits, 3 classes)
25. e54af174 (26 hits, 3 classes)
26. 49dd67fd (24 hits, 3 classes)
27. 4356bcb5 (26 hits, 3 classes)
28. e398d0fc (27 hits, 3 classes)
29. 01a2a69f (20 hits, 3 classes)
30. 659dee2e (20 hits, 3 classes)
31. 5885dd19 (27 hits, 3 classes)
32. edd542db (20 hits, 3 classes)
33. 886d55b6 (27 hits, 3 classes)
34. d139bf10 (20 hits, 3 classes)
35. 1270d940 (15 hits, 3 classes)
36. afeeae63 (20 hits, 3 classes)
37. 057f1a03 (26 hits, 3 classes)
38. 1e8c0f52 (26 hits, 3 classes)
39. aad0b191 (27 hits, 3 classes)
40. 152966c8 (20 hits, 3 classes)
41. 258d8ef8 (27 hits, 3 classes)
42. f92f6811 (26 hits, 3 classes)
43. 2ef2598b (26 hits, 3 classes)
44. 1ce28bec (27 hits, 3 classes)
45. 4a38ff34 (20 hits, 3 classes)
46. 244c6c84 (26 hits, 3 classes)
47. 2849cac0 (20 hits, 3 classes)
48. cab7aa1f (15 hits, 3 classes)
49. be84d8e8 (20 hits, 3 classes)
50. d4dfaba5 (20 hits, 3 classes)
51. 6dcb89e9 (24 hits, 3 classes)
52. d8d38ca6 (27 hits, 3 classes)
53. f9a877af (26 hits, 3 classes)
54. 85d1b621 (20 hits, 3 classes)
55. 9cc3953c (26 hits, 3 classes)
56. 3dd8255f (20 hits, 3 classes)
57. 3d5e31a8 (26 hits, 3 classes)
58. daad7c68 (27 hits, 3 classes)
59. f59f446e (20 hits, 3 classes)
60. 76266acc (20 hits, 3 classes)
61. 020458d2 (27 hits, 3 classes)
62. 7d50edc4 (16 hits, 3 classes)
63. 7dd73782 (27 hits, 3 classes)
64. eb0fed4f (26 hits, 3 classes)
65. dc13bf6c (20 hits, 3 classes)
66. fe6c757c (20 hits, 3 classes)
67. 7092f66b (20 hits, 3 classes)
68. 2fdeb3bb (20 hits, 3 classes)
69. 47d9a7c3 (20 hits, 3 classes)
70. a7ff00d7 (27 hits, 3 classes)
71. 81639331 (20 hits, 3 classes)
72. 85a15de8 (26 hits, 3 classes)
73. 0331067c (20 hits, 3 classes)
74. 2a4fc4c2 (27 hits, 3 classes)
75. a051e8d5 (27 hits, 3 classes)
76. ab7f2250 (16 hits, 3 classes)
77. 306476f0 (15 hits, 3 classes)
78. f2508e86 (16 hits, 3 classes)
79. f42d249d (20 hits, 3 classes)
80. dd024278 (20 hits, 3 classes)
81. dfe15c7a (26 hits, 3 classes)
82. 18ad1141 (20 hits, 3 classes)
83. b3f26193 (15 hits, 3 classes)
84. 0f5daa3e (27 hits, 3 classes)
85. df316c1c (27 hits, 3 classes)
86. 2c5127e8 (20 hits, 3 classes)
87. e96a49b9 (27 hits, 3 classes)
88. 7e3a9551 (20 hits, 3 classes)
89. f7a4d437 (27 hits, 3 classes)
90. 2f049562 (26 hits, 3 classes)
91. 05897674 (17 hits, 3 classes)
92. 398e6405 (20 hits, 3 classes)
93. 8a656cf6 (20 hits, 3 classes)
94. 0dc3a263 (26 hits, 3 classes)
95. bd57e60c (17 hits, 3 classes)
96. 49ab47a0 (20 hits, 3 classes)
97. 169463ad (13 hits, 3 classes)
98. e61d63ac (19 hits, 3 classes)
99. 17936a58 (20 hits, 3 classes)
100. d10bb2c0 (10 hits, 3 classes)

DemoServer$Response.java

✓ 1 lines✗ 0 lines100% covered

PrimeService.java

✓ 19 lines✗ 3 lines86% covered
1package org.isocov.demo.service;
2
3import java.util.ArrayList;
4import java.util.List;
5
6/**
7 * Sieve of Eratosthenes — nested loops with early-exit conditions.
8 */
9public final class PrimeService {
10
11 private PrimeService() {}
12
13 public static List<Integer> sieve(final int limit) {
14 if (limit < 2) {
15 return List.of();
16 }
17 final boolean[] composite = new boolean[limit + 1];
18 for (int i = 2; (long) i * i <= limit; i++) {
19 if (!composite[i]) {
20 for (int j = i * i; j <= limit; j += i) {
21 composite[j] = true;
22 }
23 }
24 }
25 final List<Integer> primes = new ArrayList<>();
26 for (int i = 2; i <= limit; i++) {
27 if (!composite[i]) {
28 primes.add(i);
29 }
30 }
31 return primes;
32 }
33
34 public static boolean isPrime(final int n) {
35 if (n < 2) {
36 return false;
37 }
38 if (n == 2) {
39 return true;
40 }
41 if (n % 2 == 0) {
42 return false;
43 }
44 for (int i = 3; (long) i * i <= n; i += 2) {
45 if (n % i == 0) {
46 return false;
47 }
48 }
49 return true;
50 }
51}

DemoServer.java

✓ 103 lines✗ 16 lines86% covered
1package org.isocov.demo;
2
3import com.sun.net.httpserver.HttpExchange;
4import com.sun.net.httpserver.HttpServer;
5import org.isocov.core.model.CompactSnapshot;
6import org.isocov.core.model.CoverageCodec;
7import org.isocov.core.runtime.CoverageRuntime;
8import org.isocov.demo.service.FizzBuzzService;
9import org.isocov.demo.service.GradeService;
10import org.isocov.demo.service.PrimeService;
11
12import java.io.IOException;
13import java.io.OutputStream;
14import java.net.InetSocketAddress;
15import java.nio.charset.StandardCharsets;
16import java.util.Arrays;
17import java.util.Collections;
18import java.util.List;
19import java.util.UUID;
20import java.util.concurrent.CopyOnWriteArrayList;
21import java.util.concurrent.Executors;
22import java.util.concurrent.ScheduledExecutorService;
23import java.util.concurrent.TimeUnit;
24
25/**
26 * Demo HTTP server showing per-request coverage with isocov.
27 *
28 * <p>Coverage flow (fully non-blocking on request thread):
29 * <ol>
30 * <li>Request thread: {@code startRequest → handle → endRequest} (auto-enqueues internally)</li>
31 * <li>Dump thread(s): dequeue → pack to {@link CompactSnapshot} → release boolean[] to pool</li>
32 * <li>{@code /coverage}: read from accumulated {@link CompactSnapshot} list</li>
33 * </ol>
34 */
35public final class DemoServer {
36
37 /** Max snapshots to retain in memory (ring buffer). */
38 private static final int MAX_SNAPSHOTS = 100;
39
40 /** Accumulated compact snapshots — written by dump threads, read by /coverage. */
41 private static final List<CompactSnapshot> STORE = new CopyOnWriteArrayList<>();
42
43 public static void main(final String[] args) throws Exception {
44 final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080;
45
46 // Periodically drain binary data and decode into compact snapshots
47 // (dump threads start automatically on first endRequest())
48 final ScheduledExecutorService drainer = Executors.newSingleThreadScheduledExecutor(r -> {
49 final Thread t = new Thread(r, "isocov-drainer");
50 t.setDaemon(true);
51 return t;
52 });
53 drainer.scheduleAtFixedRate(() -> {
54 final byte[] data = CoverageRuntime.getRequestData();
55 if (data.length > 0) {
56 STORE.addAll(CoverageCodec.decode(data));
57 while (STORE.size() > MAX_SNAPSHOTS) {
58 STORE.remove(0);
59 }
60 }
61 }, 500, 500, TimeUnit.MILLISECONDS);
62
63 final HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
64 server.setExecutor(Executors.newFixedThreadPool(4));
65
66 server.createContext("/api/grade", DemoServer::handleGrade);
67 server.createContext("/api/fizzbuzz", DemoServer::handleFizzBuzz);
68 server.createContext("/api/primes", DemoServer::handlePrimes);
69 server.createContext("/api/isprime", DemoServer::handleIsPrime);
70 server.createContext("/coverage", DemoServer::handleCoverage);
71 server.createContext("/", DemoServer::handleIndex);
72
73 server.start();
74 System.out.println("[isocov-demo] Listening on http://localhost:" + port);
75 System.out.println("[isocov-demo] Try:");
76 System.out.println(" curl 'localhost:" + port + "/api/grade?score=85'");
77 System.out.println(" curl 'localhost:" + port + "/api/fizzbuzz?n=20'");
78 System.out.println(" curl 'localhost:" + port + "/api/primes?limit=50'");
79 System.out.println(" open http://localhost:" + port + "/coverage");
80 }
81
82 // ─── Handlers ─────────────────────────────────────────────────────────────
83
84 private static void handleGrade(final HttpExchange ex) throws IOException {
85 withCoverage(ex, () -> {
86 final int score = intParam(ex, "score", 75);
87 final String g = GradeService.grade(score);
88 final String c = GradeService.comment(score);
89 return json("{\"score\":" + score + ",\"grade\":\"" + g + "\",\"comment\":\"" + c + "\"}");
90 });
91 }
92
93 private static void handleFizzBuzz(final HttpExchange ex) throws IOException {
94 withCoverage(ex, () -> {
95 final int n = intParam(ex, "n", 15);
96 final var list = FizzBuzzService.compute(n);
97 return json("[" + String.join(",", list.stream().map(s -> "\"" + s + "\"").toList()) + "]");
98 });
99 }
100
101 private static void handlePrimes(final HttpExchange ex) throws IOException {
102 withCoverage(ex, () -> {
103 final int limit = intParam(ex, "limit", 50);
104 final var primes = PrimeService.sieve(limit);
105 return json("{\"limit\":" + limit + ",\"count\":" + primes.size()
106 + ",\"primes\":" + primes + "}");
107 });
108 }
109
110 private static void handleIsPrime(final HttpExchange ex) throws IOException {
111 withCoverage(ex, () -> {
112 final int n = intParam(ex, "n", 17);
113 return json("{\"n\":" + n + ",\"prime\":" + PrimeService.isPrime(n) + "}");
114 });
115 }
116
117 private static void handleCoverage(final HttpExchange ex) throws IOException {
118 final List<CompactSnapshot> all = Collections.unmodifiableList(STORE);
119 if (all.isEmpty()) {
120 send(ex, 200, "text/plain", "No coverage yet. Hit an /api/* endpoint first.");
121 return;
122 }
123 try {
124 final String reqParam = stringParam(ex, "req", "all");
125 final int selected;
126 if ("all".equals(reqParam)) {
127 selected = -1;
128 } else {
129 try {
130 selected = Integer.parseInt(reqParam);
131 } catch (final NumberFormatException e) {
132 send(ex, 400, "text/plain", "Invalid req param: " + reqParam);
133 return;
134 }
135 if (selected < 1 || selected > all.size()) {
136 send(ex, 400, "text/plain",
137 "req must be between 1 and " + all.size());
138 return;
139 }
140 }
141
142 // Include background coverage in flat view
143 CompactSnapshot background = null;
144 if (selected == -1) {
145 final byte[] bgData = CoverageRuntime.getBackgroundData(false);
146 if (bgData.length > 0) {
147 final List<CompactSnapshot> bgSnaps = CoverageCodec.decode(bgData);
148 if (!bgSnaps.isEmpty()) {
149 background = bgSnaps.get(0);
150 }
151 }
152 }
153
154 final String view = stringParam(ex, "view", "coverage");
155 final String html = CoverageReporter.toHtml(
156 all, selected, background, "heatmap".equals(view));
157 send(ex, 200, "text/html; charset=UTF-8", html);
158 } catch (final Exception e) {
159 send(ex, 500, "text/plain", "Error generating report: " + e.getMessage());
160 }
161 }
162
163 private static void handleIndex(final HttpExchange ex) throws IOException {
164 send(ex, 200, "text/html", """
165 <html><body style="font-family:monospace;padding:20px">
166 <h2>isocov demo</h2>
167 <ul>
168 <li><a href="/api/grade?score=85">/api/grade?score=85</a></li>
169 <li><a href="/api/fizzbuzz?n=20">/api/fizzbuzz?n=20</a></li>
170 <li><a href="/api/primes?limit=50">/api/primes?limit=50</a></li>
171 <li><a href="/api/isprime?n=17">/api/isprime?n=17</a></li>
172 <li><a href="/coverage">/coverage</a> ← hit an endpoint first</li>
173 </ul>
174 </body></html>
175 """);
176 }
177
178 // ─── Coverage wrapper ──────────────────────────────────────────────────────
179
180 @FunctionalInterface
181 interface Handler { Response handle() throws Exception; }
182 record Response(String contentType, String body) {}
183
184 private static Response json(final String body) {
185 return new Response("application/json", body);
186 }
187
188 /**
189 * Non-blocking coverage collection:
190 * startRequest → handle → endRequest (auto-enqueues to dump thread internally)
191 * Request thread never blocks on coverage processing.
192 */
193 private static void withCoverage(final HttpExchange ex, final Handler handler)
194 throws IOException {
195 final String reqId = UUID.randomUUID().toString().substring(0, 8);
196 CoverageRuntime.startRequest(reqId);
197 String body;
198 String contentType;
199 try {
200 final Response resp = handler.handle();
201 body = resp.body();
202 contentType = resp.contentType();
203 } catch (final Exception e) {
204 body = "{\"error\":\"" + e.getMessage() + "\"}";
205 contentType = "application/json";
206 } finally {
207 CoverageRuntime.endRequest(); // non-blocking: enqueues to dump thread internally
208 }
209 send(ex, 200, contentType, body);
210 }
211
212 // ─── Helpers ──────────────────────────────────────────────────────────────
213
214 private static String stringParam(final HttpExchange ex, final String key, final String defaultVal) {
215 final String query = ex.getRequestURI().getQuery();
216 if (query == null) {
217 return defaultVal;
218 }
219 return Arrays.stream(query.split("&"))
220 .filter(p -> p.startsWith(key + "="))
221 .map(p -> p.substring(key.length() + 1))
222 .findFirst().orElse(defaultVal);
223 }
224
225 private static int intParam(final HttpExchange ex, final String key, final int defaultVal) {
226 final String query = ex.getRequestURI().getQuery();
227 if (query == null) {
228 return defaultVal;
229 }
230 return Arrays.stream(query.split("&"))
231 .filter(p -> p.startsWith(key + "="))
232 .map(p -> p.substring(key.length() + 1))
233 .mapToInt(v -> {
234 try {
235 return Integer.parseInt(v);
236 } catch (Exception e) {
237 return defaultVal;
238 }
239 })
240 .findFirst().orElse(defaultVal);
241 }
242
243 private static void send(final HttpExchange ex, final int status,
244 final String contentType, final String body) throws IOException {
245 final byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
246 ex.getResponseHeaders().set("Content-Type", contentType);
247 ex.sendResponseHeaders(status, bytes.length);
248 try (OutputStream os = ex.getResponseBody()) {
249 os.write(bytes);
250 }
251 }
252}

GradeService.java

✓ 16 lines✗ 2 lines88% covered
1package org.isocov.demo.service;
2
3/**
4 * Assigns letter grades based on numeric score.
5 * Rich branching — good for coverage demos.
6 */
7public final class GradeService {
8
9 private GradeService() {}
10
11 public static String grade(final int score) {
12 if (score < 0 || score > 100) {
13 return "INVALID";
14 }
15 if (score >= 90) {
16 return "A";
17 }
18 if (score >= 80) {
19 return "B";
20 }
21 if (score >= 70) {
22 return "C";
23 }
24 if (score >= 60) {
25 return "D";
26 }
27 return "F";
28 }
29
30 public static String comment(final int score) {
31 return switch (grade(score)) {
32 case "A" -> "Excellent!";
33 case "B" -> "Good job.";
34 case "C" -> "Average.";
35 case "D" -> "Needs improvement.";
36 case "F" -> "Failed.";
37 default -> "Invalid score.";
38 };
39 }
40}

FizzBuzzService.java

✓ 11 lines✗ 1 lines91% covered
1package org.isocov.demo.service;
2
3import java.util.ArrayList;
4import java.util.List;
5
6/**
7 * FizzBuzz — classic loop with multiple branch conditions.
8 */
9public final class FizzBuzzService {
10
11 private FizzBuzzService() {}
12
13 public static List<String> compute(final int n) {
14 if (n <= 0) {
15 return List.of();
16 }
17 final List<String> result = new ArrayList<>(n);
18 for (int i = 1; i <= n; i++) {
19 if (i % 15 == 0) {
20 result.add("FizzBuzz");
21 } else if (i % 3 == 0) {
22 result.add("Fizz");
23 } else if (i % 5 == 0) {
24 result.add("Buzz");
25 } else {
26 result.add(String.valueOf(i));
27 }
28 }
29 return result;
30 }
31}

CoverageReporter.java

✓ 183 lines✗ 4 lines97% covered
1package org.isocov.demo;
2
3import org.jacoco.core.analysis.Analyzer;
4import org.jacoco.core.analysis.CoverageBuilder;
5import org.jacoco.core.analysis.IClassCoverage;
6import org.jacoco.core.analysis.ICounter;
7import org.jacoco.core.analysis.ILine;
8import org.jacoco.core.data.ExecutionData;
9import org.jacoco.core.data.ExecutionDataStore;
10import org.jacoco.core.internal.data.CRC64;
11import org.isocov.core.model.CompactSnapshot;
12
13import java.io.BufferedReader;
14import java.io.IOException;
15import java.io.InputStream;
16import java.io.InputStreamReader;
17import java.nio.charset.StandardCharsets;
18import java.util.ArrayList;
19import java.util.LinkedHashMap;
20import java.util.List;
21import java.util.Map;
22
23/**
24 * Converts {@link CompactSnapshot} data into an HTML report using JaCoCo's Analyzer.
25 *
26 * <p>Supports two view modes:
27 * <ul>
28 * <li><b>Coverage</b> — JaCoCo-style hit/miss/partial coloring</li>
29 * <li><b>Heatmap</b> — line intensity based on how many requests hit each line</li>
30 * </ul>
31 */
32final class CoverageReporter {
33
34 private CoverageReporter() {}
35
36 /**
37 * @param snapshots all collected compact snapshots
38 * @param selected 1-based index of a single request to show, or -1 for cumulative (all)
39 * @param background background coverage snapshot, or null to exclude
40 * @param heatmap true for heatmap view, false for coverage view
41 */
42 static String toHtml(final List<CompactSnapshot> snapshots, final int selected,
43 final CompactSnapshot background,
44 final boolean heatmap) throws Exception {
45 // Determine which snapshots to include in the coverage view
46 final List<CompactSnapshot> active = selected == -1
47 ? snapshots
48 : List.of(snapshots.get(selected - 1));
49
50 // Merge unpacked probe arrays: OR across active snapshots per class
51 final Map<String, boolean[]> merged = new LinkedHashMap<>();
52 for (final CompactSnapshot snap : active) {
53 mergeInto(merged, snap);
54 }
55
56 // Include background coverage in flat view
57 if (background != null) {
58 mergeInto(merged, background);
59 }
60
61 // For heatmap: count per-line hits across all snapshots
62 // Key: className → lineNum → hitCount
63 final Map<String, Map<Integer, Integer>> lineHits;
64 int maxHits = 1;
65 if (heatmap && selected == -1) {
66 lineHits = new LinkedHashMap<>();
67 for (final CompactSnapshot snap : active) {
68 final Map<String, boolean[]> unpacked = snap.unpackAll();
69 for (final Map.Entry<String, boolean[]> e : unpacked.entrySet()) {
70 final String className = e.getKey();
71 final boolean[] probes = e.getValue();
72 final IClassCoverage cov = analyze(className, probes);
73 if (cov == null) {
74 continue;
75 }
76 final Map<Integer, Integer> counts =
77 lineHits.computeIfAbsent(className, k -> new LinkedHashMap<>());
78 for (int line = cov.getFirstLine(); line <= cov.getLastLine(); line++) {
79 final int status = cov.getLine(line).getStatus();
80 if (status == ICounter.FULLY_COVERED
81 || status == ICounter.PARTLY_COVERED) {
82 final int c = counts.merge(line, 1, Integer::sum);
83 if (c > maxHits) {
84 maxHits = c;
85 }
86 }
87 }
88 }
89 }
90 } else {
91 lineHits = null;
92 }
93
94 final StringBuilder html = new StringBuilder();
95 appendHeader(html, heatmap);
96
97 final String title;
98 if (heatmap) {
99 title = "isocov — heatmap (" + active.size() + " requests)";
100 } else {
101 title = selected == -1
102 ? "isocov — cumulative coverage"
103 : "isocov — request #" + selected;
104 }
105 html.append("<h1>").append(title)
106 .append(" <a href='https://github.com/isocov/isocov' ")
107 .append("style='font-size:0.5em;color:#858585;text-decoration:none;vertical-align:middle'")
108 .append(">GitHub</a>")
109 .append("</h1>\n");
110
111 // View toggle (only in all view)
112 if (selected == -1) {
113 html.append("<div class='view-toggle'>");
114 html.append("<a href='/coverage?view=coverage'")
115 .append(heatmap ? "" : " class='active'")
116 .append(">Coverage</a>");
117 html.append("<a href='/coverage?view=heatmap'")
118 .append(heatmap ? " class='active'" : "")
119 .append(">Heatmap</a>");
120 html.append("</div>\n");
121 }
122
123 html.append("<div class='meta'>")
124 .append("showing: <b>")
125 .append(selected == -1 ? "all" : selected + " of " + snapshots.size())
126 .append("</b> &nbsp;|&nbsp; ")
127 .append("classes: <b>").append(merged.size()).append("</b>")
128 .append("</div>\n");
129
130 // Request history — clickable
131 html.append("<details class='req-list'");
132 if (selected != -1) {
133 html.append(" open");
134 }
135 html.append(">\n");
136 html.append("<summary>Request history (").append(snapshots.size())
137 .append(" requests)</summary>\n");
138 final String allClass = selected == -1 ? " class='active'" : "";
139 html.append("<a href='/coverage?req=all'").append(allClass).append(">[all]</a><br>");
140 for (int i = 0; i < snapshots.size(); i++) {
141 final CompactSnapshot s = snapshots.get(i);
142 final int num = i + 1;
143 final String cls = num == selected ? " class='active'" : "";
144 html.append("<a href='/coverage?req=").append(num).append("'").append(cls).append(">")
145 .append(num).append(". ")
146 .append(s.contextId()).append(" (")
147 .append(s.totalHits()).append(" hits, ")
148 .append(s.probes().size()).append(" classes)")
149 .append("</a>");
150 if (i < snapshots.size() - 1) {
151 html.append("<br>");
152 }
153 }
154 html.append("</details>\n");
155
156 if (heatmap && lineHits != null) {
157 // Legend
158 html.append("<div class='legend'>")
159 .append("<span>cold</span>");
160 for (int i = 0; i <= 4; i++) {
161 final double ratio = i / 4.0;
162 html.append("<span class='swatch' style='background:")
163 .append(heatColor(ratio)).append("'></span>");
164 }
165 html.append("<span>hot (").append(maxHits).append(" hits)</span>")
166 .append("</div>\n");
167 }
168
169 // Render each class
170 for (final Map.Entry<String, boolean[]> entry : merged.entrySet()) {
171 final String internalName = entry.getKey();
172 final boolean[] probes = entry.getValue();
173
174 final IClassCoverage classCov = analyze(internalName, probes);
175 if (classCov == null) {
176 continue;
177 }
178
179 final String simpleName = internalName.replaceAll(".*/", "");
180 final String sourcePath = "sources/" + internalName + ".java";
181 final List<String> sourceLines = readSource(sourcePath);
182
183 final int coveredLines = classCov.getLineCounter().getCoveredCount();
184 final int totalLines = classCov.getLineCounter().getTotalCount();
185 final int pct = totalLines == 0 ? 0 : coveredLines * 100 / totalLines;
186
187 html.append("<h2>").append(simpleName).append(".java</h2>\n");
188 html.append("<span class='stats cov'>✓ ").append(coveredLines)
189 .append(" lines</span>");
190 html.append("<span class='stats miss'>✗ ")
191 .append(totalLines - coveredLines).append(" lines</span>");
192 html.append("<span class='stats'>").append(pct).append("% covered</span>\n");
193
194 html.append("<table>\n");
195 final int firstLine = classCov.getFirstLine();
196 final int lastLine = classCov.getLastLine();
197 final Map<Integer, Integer> classLineHits =
198 lineHits != null ? lineHits.get(internalName) : null;
199 final int finalMaxHits = maxHits;
200
201 for (int i = 0; i < sourceLines.size(); i++) {
202 final int lineNum = i + 1;
203 final String src = escapeHtml(sourceLines.get(i));
204
205 if (heatmap && classLineHits != null) {
206 // Heatmap mode: color by hit count intensity
207 final int hits = classLineHits.getOrDefault(lineNum, 0);
208 if (hits > 0) {
209 final double ratio = (double) hits / finalMaxHits;
210 html.append("<tr style='background:").append(heatColor(ratio))
211 .append("'>");
212 html.append("<td class='ln'>").append(lineNum).append("</td>");
213 html.append("<td class='heat-count'>").append(hits).append("</td>");
214 html.append("<td>").append(src).append("</td>");
215 } else {
216 html.append("<tr>");
217 html.append("<td class='ln'>").append(lineNum).append("</td>");
218 html.append("<td class='heat-count'></td>");
219 html.append("<td>").append(src).append("</td>");
220 }
221 } else {
222 // Coverage mode: JaCoCo-style coloring
223 String cssClass = "none";
224 if (lineNum >= firstLine && lineNum <= lastLine) {
225 final ILine line = classCov.getLine(lineNum);
226 cssClass = switch (line.getStatus()) {
227 case ICounter.FULLY_COVERED -> "hit";
228 case ICounter.NOT_COVERED -> "miss-line";
229 case ICounter.PARTLY_COVERED -> "part";
230 default -> "none";
231 };
232 }
233 html.append("<tr class='").append(cssClass).append("'>");
234 html.append("<td class='ln'>").append(lineNum).append("</td>");
235 html.append("<td>").append(src).append("</td>");
236 }
237 html.append("</tr>\n");
238 }
239 html.append("</table>\n");
240 }
241
242 html.append("</body></html>\n");
243 return html.toString();
244 }
245
246 // ─── Helpers ─────────────────────────────────────────────────────────────
247
248 /**
249 * Run JaCoCo Analyzer on a single class with given probes.
250 */
251 private static IClassCoverage analyze(final String internalName,
252 final boolean[] probes) throws Exception {
253 final String resourcePath = internalName + ".class";
254 final InputStream in = CoverageReporter.class.getClassLoader()
255 .getResourceAsStream(resourcePath);
256 if (in == null) {
257 return null;
258 }
259 final byte[] classBytes;
260 try (in) {
261 classBytes = in.readAllBytes();
262 }
263 final long classId = CRC64.classId(classBytes);
264 final ExecutionDataStore store = new ExecutionDataStore();
265 store.put(new ExecutionData(classId, internalName, probes));
266 final CoverageBuilder builder = new CoverageBuilder();
267 new Analyzer(store, builder).analyzeClass(classBytes, internalName);
268 return builder.getClasses().stream()
269 .filter(c -> c.getName().equals(internalName))
270 .findFirst().orElse(null);
271 }
272
273 /**
274 * Generate a heat color from cold (dark blue) to hot (bright red/orange).
275 */
276 private static String heatColor(final double ratio) {
277 // 0.0 = cold (#1e2a3a dark blue) → 1.0 = hot (#ff4500 orange-red)
278 final int r = (int) (30 + ratio * 225); // 30 → 255
279 final int g = (int) (42 + ratio * 27); // 42 → 69 (stays dark-ish)
280 final int b = (int) (58 - ratio * 58); // 58 → 0
281 return String.format("#%02x%02x%02x", r, g, b);
282 }
283
284 private static void appendHeader(final StringBuilder html, final boolean heatmap) {
285 html.append("""
286 <!DOCTYPE html>
287 <html>
288 <head>
289 <meta charset="UTF-8">
290 <title>isocov coverage</title>
291 <style>
292 body { font-family: sans-serif; background: #1e1e1e; color: #d4d4d4; margin: 0; padding: 20px; }
293 h1 { color: #569cd6; border-bottom: 1px solid #333; padding-bottom: 8px; }
294 h2 { color: #9cdcfe; margin-top: 32px; font-size: 1em; }
295 h3 { color: #dcdcaa; margin-top: 24px; font-size: 0.9em; }
296 .meta { color: #6a9955; font-size: 0.85em; margin-bottom: 24px; }
297 .stats { display: inline-block; background: #252526; border: 1px solid #333;
298 border-radius: 4px; padding: 4px 12px; margin-right: 8px; font-size: 0.85em; }
299 .cov { color: #4ec9b0; }
300 .miss { color: #f48771; }
301 .req-list { background: #252526; border: 1px solid #333; border-radius: 4px;
302 padding: 12px 16px; margin-bottom: 24px; font-size: 0.85em; font-family: monospace; }
303 .req-list a { color: #9cdcfe; text-decoration: none; margin-right: 12px; display: inline-block; }
304 .req-list a:hover { text-decoration: underline; }
305 .req-list a.active { color: #4ec9b0; font-weight: bold; }
306 .req-list summary { cursor: pointer; color: #dcdcaa; font-size: 1em; margin-bottom: 8px; }
307 .view-toggle { margin-bottom: 16px; }
308 .view-toggle a { display: inline-block; padding: 6px 16px; margin-right: 4px;
309 background: #252526; border: 1px solid #333; border-radius: 4px;
310 color: #9cdcfe; text-decoration: none; font-size: 0.9em; }
311 .view-toggle a:hover { background: #333; }
312 .view-toggle a.active { background: #264f78; border-color: #569cd6; color: #fff; }
313 .legend { margin-bottom: 16px; font-size: 0.85em; display: flex; align-items: center; gap: 4px; }
314 .swatch { display: inline-block; width: 24px; height: 14px; border-radius: 2px; }
315 table { border-collapse: collapse; width: 100%; font-family: monospace; font-size: 0.88em; }
316 td { padding: 1px 8px; white-space: pre; }
317 .ln { color: #858585; text-align: right; width: 40px; user-select: none; border-right: 1px solid #333; }
318 .heat-count { color: #858585; text-align: right; width: 30px; font-size: 0.8em;
319 border-right: 1px solid #333; user-select: none; }
320 .hit { background: #1e3a1e; }
321 .miss-line { background: #3a1e1e; }
322 .part { background: #2e2e1e; }
323 .none { }
324 </style>
325 </head>
326 <body>
327 """);
328 }
329
330 private static void mergeInto(final Map<String, boolean[]> merged,
331 final CompactSnapshot snap) {
332 for (final Map.Entry<String, boolean[]> e : snap.unpackAll().entrySet()) {
333 merged.merge(e.getKey(), e.getValue(), (existing, incoming) -> {
334 for (int i = 0; i < existing.length && i < incoming.length; i++) {
335 existing[i] |= incoming[i];
336 }
337 return existing;
338 });
339 }
340 }
341
342 private static List<String> readSource(final String resourcePath) {
343 final List<String> lines = new ArrayList<>();
344 final InputStream in = CoverageReporter.class.getClassLoader()
345 .getResourceAsStream(resourcePath);
346 if (in == null) {
347 return lines;
348 }
349 try (BufferedReader reader = new BufferedReader(
350 new InputStreamReader(in, StandardCharsets.UTF_8))) {
351 reader.lines().forEach(lines::add);
352 } catch (final IOException ignored) {
353 }
354 return lines;
355 }
356
357 private static String escapeHtml(final String s) {
358 return s.replace("&", "&amp;")
359 .replace("<", "&lt;")
360 .replace(">", "&gt;")
361 .replace("\t", " ");
362 }
363}