aboutsummaryrefslogtreecommitdiff
path: root/scripts/coverage-summary.el
blob: 51ddda966b205e565d4f9b1fbcb2384e11fd3d6c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
;;; coverage-summary.el --- Terminal coverage summary for duet -*- lexical-binding: t; -*-

;; Copyright (C) 2026 Craig Jennings

;; Author: Craig Jennings <c@cjennings.net>

;; This program is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <http://www.gnu.org/licenses/>.

;;; Commentary:

;; Batch helper behind `make coverage-summary'.  Parses the SimpleCov JSON that
;; `make coverage' writes and prints a terminal summary instead of just the
;; report's file size:
;;
;;   - per tracked source file: covered/total executable lines and a percent,
;;     worst-covered first;
;;   - a line-weighted project figure over files present in the report;
;;   - a source-weighted figure where a tracked source missing from the report
;;     counts as 0% rather than being silently dropped; and
;;   - an explicit list of tracked sources absent from the report.
;;
;; The missing-source handling is the robustness win: a source that never got
;; instrumented shows as 0% instead of vanishing from the numbers.  DUET ships
;; a single source file today, so this is generalized over a list of tracked
;; sources and stays self-contained.

;;; Code:

(require 'json)
(require 'seq)
(require 'subr-x)

(defun duet-coverage--read-json (file)
  "Parse FILE as SimpleCov JSON and return the decoded hash table.
Signal `user-error' when FILE is missing or the JSON is malformed."
  (unless (file-exists-p file)
    (user-error "Coverage report not found: %s (run 'make coverage' first)" file))
  (let ((json-object-type 'hash-table)
        (json-array-type 'list)
        (json-key-type 'string))
    (condition-case err
        (json-read-file file)
      (error (user-error "Malformed coverage JSON in %s: %s"
                         file (error-message-string err))))))

(defun duet-coverage--collect (file predicate)
  "Return path -> (line -> t) hash for lines in FILE matching PREDICATE.
PREDICATE receives each line's SimpleCov hits entry (a number, or nil for a
non-executable line).  Coverage is unioned across every top-level test-name key
so undercover's `:merge-report' output accumulates correctly.  A path is
recorded even when no line matches, so callers can tell \"present but empty\"
from \"absent\"."
  (let ((data (duet-coverage--read-json file))
        (result (make-hash-table :test 'equal)))
    (maphash
     (lambda (_test-name section)
       (when (hash-table-p section)
         (let ((coverage (gethash "coverage" section)))
           (when (hash-table-p coverage)
             (maphash
              (lambda (path hits-list)
                (let ((lines (or (gethash path result)
                                 (make-hash-table :test 'eql)))
                      (line-num 1))
                  (dolist (hits hits-list)
                    (when (funcall predicate hits)
                      (puthash line-num t lines))
                    (setq line-num (1+ line-num)))
                  (puthash path lines result)))
              coverage)))))
     data)
    result))

(defun duet-coverage--parse-simplecov (file)
  "Return path -> (line -> t) hash for HIT lines (hits > 0) in FILE."
  (duet-coverage--collect file (lambda (h) (and (numberp h) (> h 0)))))

(defun duet-coverage--executable-lines (file)
  "Return path -> (line -> t) hash for EXECUTABLE lines in FILE.
Executable means the SimpleCov entry is a number (hit or unhit); null entries
\(blank lines, comments) are excluded."
  (duet-coverage--collect file #'numberp))

(defun duet-coverage--line-count (table path)
  "Return the number of recorded lines for PATH in TABLE, or 0 when absent."
  (let ((lines (gethash path table)))
    (if (hash-table-p lines) (hash-table-count lines) 0)))

(defun duet-coverage--find-key (table abs)
  "Return the key in TABLE matching ABS, or nil.
Matches by expanded path first, then by basename, so a report whose paths are
written in a slightly different but equivalent form still resolves."
  (let ((want-base (file-name-nondirectory abs))
        (found nil))
    (catch 'done
      (maphash (lambda (k _v)
                 (when (or (string= (expand-file-name k) abs)
                           (string= (file-name-nondirectory k) want-base))
                   (setq found k)
                   (throw 'done k)))
               table))
    found))

(defun duet-coverage--records (report-file source-files project-root)
  "Return per-file coverage records for SOURCE-FILES from REPORT-FILE.
SOURCE-FILES are paths relative to PROJECT-ROOT.  Each record is a plist:

  (:path REL :covered N :total N :present BOOL :percent FLOAT)

A source absent from the report has :present nil, :covered 0, :total 0, and
:percent 0.0 — it counts as 0%, not 100%.  A present source with no executable
lines is 100% (nothing left to cover)."
  (let ((covered (duet-coverage--parse-simplecov report-file))
        (executable (duet-coverage--executable-lines report-file))
        (root (file-name-as-directory (expand-file-name project-root))))
    (mapcar
     (lambda (rel)
       (let* ((abs (expand-file-name rel root))
              (key (duet-coverage--find-key executable abs))
              (present (and key t))
              (total (if key (duet-coverage--line-count executable key) 0))
              (cov (if key (duet-coverage--line-count covered key) 0))
              (percent (cond ((not present) 0.0)
                             ((zerop total) 100.0)
                             (t (/ (* 100.0 cov) total)))))
         (list :path rel :covered cov :total total
               :present present :percent percent)))
     source-files)))

(defun duet-coverage-summary-text (report-file source-files project-root)
  "Return a coverage summary string for SOURCE-FILES from REPORT-FILE.
SOURCE-FILES are paths relative to PROJECT-ROOT."
  (let* ((records (duet-coverage--records report-file source-files project-root))
         (present (seq-filter (lambda (r) (plist-get r :present)) records))
         (missing (seq-remove (lambda (r) (plist-get r :present)) records))
         (total-cov (apply #'+ (mapcar (lambda (r) (plist-get r :covered)) present)))
         (total-lines (apply #'+ (mapcar (lambda (r) (plist-get r :total)) present)))
         (line-pct (if (> total-lines 0) (/ (* 100.0 total-cov) total-lines) 100.0))
         (file-score (apply #'+ (mapcar (lambda (r) (plist-get r :percent)) records)))
         (file-pct (if records (/ file-score (length records)) 0.0))
         (sorted (sort (copy-sequence records)
                       (lambda (a b) (< (plist-get a :percent) (plist-get b :percent)))))
         (width (apply #'max 8 (mapcar (lambda (r) (length (plist-get r :path))) records)))
         (row-format (format "  %%-%ds  %%5d/%%-5d  (%%5.1f%%%%)\n" width))
         (miss-format (format "  %%-%ds  not in report (counts as 0%%%%)\n" width)))
    (with-temp-buffer
      (insert "Coverage Summary\n================\n\n")
      (insert "Per-file coverage (worst first):\n")
      (dolist (r sorted)
        (if (plist-get r :present)
            (insert (format row-format
                            (plist-get r :path)
                            (plist-get r :covered)
                            (plist-get r :total)
                            (plist-get r :percent)))
          (insert (format miss-format (plist-get r :path)))))
      (insert "\n")
      (insert (format "Line coverage:   %d/%d lines (%.1f%%) across %d file%s in report\n"
                      total-cov total-lines line-pct (length present)
                      (if (= 1 (length present)) "" "s")))
      (insert (format "Source coverage: %.1f%% (%d tracked, %d missing; missing counts as 0%%)\n"
                      file-pct (length present) (length missing)))
      (when missing
        (insert (format "\nNot in coverage report: %d source%s\n"
                        (length missing) (if (= 1 (length missing)) "" "s")))
        (dolist (r missing)
          (insert (format "  %s\n" (plist-get r :path)))))
      (buffer-string))))

(defun duet-coverage-print-summary (report-file source-files project-root)
  "Print the coverage summary for SOURCE-FILES to standard output.
SOURCE-FILES are paths relative to PROJECT-ROOT.  Entry point for
`make coverage-summary'."
  (princ "\n")
  (princ (duet-coverage-summary-text report-file source-files project-root)))

(provide 'duet-coverage-summary)
;;; coverage-summary.el ends here