| 1 | package org.isocov.demo; |
| 2 | |
| 3 | import com.sun.net.httpserver.HttpExchange; |
| 4 | import com.sun.net.httpserver.HttpServer; |
| 5 | import org.isocov.core.model.CompactSnapshot; |
| 6 | import org.isocov.core.model.CoverageCodec; |
| 7 | import org.isocov.core.runtime.CoverageRuntime; |
| 8 | import org.isocov.demo.service.FizzBuzzService; |
| 9 | import org.isocov.demo.service.GradeService; |
| 10 | import org.isocov.demo.service.PrimeService; |
| 11 | |
| 12 | import java.io.IOException; |
| 13 | import java.io.OutputStream; |
| 14 | import java.net.InetSocketAddress; |
| 15 | import java.nio.charset.StandardCharsets; |
| 16 | import java.util.Arrays; |
| 17 | import java.util.Collections; |
| 18 | import java.util.List; |
| 19 | import java.util.UUID; |
| 20 | import java.util.concurrent.CopyOnWriteArrayList; |
| 21 | import java.util.concurrent.Executors; |
| 22 | import java.util.concurrent.ScheduledExecutorService; |
| 23 | import 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 | */ |
| 35 | public 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 | } |
| 1 | package org.isocov.demo; |
| 2 | |
| 3 | import org.jacoco.core.analysis.Analyzer; |
| 4 | import org.jacoco.core.analysis.CoverageBuilder; |
| 5 | import org.jacoco.core.analysis.IClassCoverage; |
| 6 | import org.jacoco.core.analysis.ICounter; |
| 7 | import org.jacoco.core.analysis.ILine; |
| 8 | import org.jacoco.core.data.ExecutionData; |
| 9 | import org.jacoco.core.data.ExecutionDataStore; |
| 10 | import org.jacoco.core.internal.data.CRC64; |
| 11 | import org.isocov.core.model.CompactSnapshot; |
| 12 | |
| 13 | import java.io.BufferedReader; |
| 14 | import java.io.IOException; |
| 15 | import java.io.InputStream; |
| 16 | import java.io.InputStreamReader; |
| 17 | import java.nio.charset.StandardCharsets; |
| 18 | import java.util.ArrayList; |
| 19 | import java.util.LinkedHashMap; |
| 20 | import java.util.List; |
| 21 | import 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 | */ |
| 32 | final 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> | ") |
| 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("&", "&") |
| 359 | .replace("<", "<") |
| 360 | .replace(">", ">") |
| 361 | .replace("\t", " "); |
| 362 | } |
| 363 | } |