aboutsummaryrefslogtreecommitdiff
path: root/modules/coverage-core.el
blob: bda90612c8b6a5794f5c5911ff2e7d2a636821c4 (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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
;;; coverage-core.el --- Coverage reporting engine and backend registry -*- lexical-binding: t; coding: utf-8; -*-
;; author: Craig Jennings <c@cjennings.net>

;;; Commentary:
;; Language-agnostic core for diff-aware coverage reporting.
;;
;; Reads an LCOV file, shells to git diff at a selectable scope,
;; intersects the results, and displays a report buffer.  Languages
;; plug in via the backend registry (see `cj/coverage-backends').
;;
;; See docs/design/coverage.org for the design rationale.

;;; Code:

(require 'seq)

(defvar cj/coverage-backends nil
  "Registry of coverage backends in priority order.
Each entry is a plist with at least :name, :detect, :run, and :report-path.
Use `cj/coverage-register-backend' to add or replace an entry.")

(defvar-local cj/coverage-backend nil
  "Override: name of the coverage backend to use for the current project.
When nil (the default), resolution runs each registered backend's :detect
function in registration order.  Typically set buffer-locally via
`.dir-locals.el' to pin a specific backend.")

(defun cj/coverage-register-backend (backend)
  "Register BACKEND, a plist with :name, :detect, :run, :report-path.
Appends to `cj/coverage-backends' at the end, or replaces the existing
entry with the same :name in its current position."
  (let ((name (plist-get backend :name)))
	(if (cj/--coverage-backend-by-name name)
		(setq cj/coverage-backends
			  (mapcar (lambda (b)
						(if (eq (plist-get b :name) name) backend b))
					  cj/coverage-backends))
	  (setq cj/coverage-backends
			(append cj/coverage-backends (list backend))))))

(defun cj/--coverage-backend-by-name (name)
  "Return the registered backend whose :name equals NAME, or nil."
  (seq-find (lambda (b) (eq name (plist-get b :name)))
			cj/coverage-backends))

(defun cj/--coverage-backend-for-project (root &optional override)
  "Resolve the coverage backend to use for ROOT.
OVERRIDE, if non-nil, is a backend name symbol (typically the value of
`cj/coverage-backend' from .dir-locals.el).  When given, the named
backend is returned regardless of any :detect functions.  Signals
`user-error' when OVERRIDE names a backend that isn't registered.

When OVERRIDE is nil, each backend's :detect is called in turn with
ROOT as its sole argument; the first that returns non-nil wins.
Returns the backend plist, or nil when no backend matches."
  (cond
   (override
	(or (cj/--coverage-backend-by-name override)
		(user-error
		 "Unknown coverage backend: %s (registered: %s)"
		 override
		 (mapcar (lambda (b) (plist-get b :name)) cj/coverage-backends))))
   (t
	(seq-find (lambda (backend)
				(funcall (plist-get backend :detect) root))
			  cj/coverage-backends))))

(defun cj/--coverage-parse-simplecov (file)
  "Parse FILE as a simplecov JSON report and return covered lines per file.
Keys are source-file paths (strings).  Values are hash tables whose
keys are line numbers (integers) with a hit count greater than zero.
Lines marked nil (not executable) or 0 (executable but not hit) are
excluded.

Simplecov JSON structure is:
  { <test-name>: { \"coverage\": { <path>: [null | 0 | int, ...] } } }

When the JSON contains multiple top-level test-name keys, coverage
data is unioned across them; useful for files produced with undercover's
`:merge-report t' option that accumulate runs under a shared key, and
also for defensive handling of unexpected multi-key shapes.

Signals `user-error' if FILE does not exist or contains malformed JSON."
  (unless (file-exists-p file)
	(user-error "Simplecov report not found: %s" file))
  (require 'json)
  (let* ((json-object-type 'hash-table)
		 (json-array-type 'list)
		 (json-key-type 'string)
		 (data (condition-case err
				   (json-read-file file)
				 (error (user-error "Malformed simplecov JSON in %s: %s"
									file (error-message-string err)))))
		 (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 (and (numberp hits) (> hits 0))
					  (puthash line-num t lines))
					(setq line-num (1+ line-num)))
				  (puthash path lines result)))
			  coverage)))))
	 data)
	result))

(defconst cj/--coverage-hunk-header-regexp
  "^@@ -[0-9]+\\(,[0-9]+\\)? \\+\\([0-9]+\\)\\(,\\([0-9]+\\)\\)? @@"
  "Regexp for a git unified-diff hunk header.
Captures new_start (group 2) and new_count (group 4; nil implies 1).")

(defconst cj/--coverage-file-marker-regexp
  "^\\+\\+\\+ b/\\(.+\\)$"
  "Regexp for the \"+++ b/<path>\" line of a git diff.
Captures the file path (group 1).")

(defun cj/--coverage-parse-diff-output (output)
  "Parse OUTPUT, a git unified-diff string, into a hash table.
Keys are file paths (relative to repo root, as git emits them).  Values
are hash tables whose keys are line numbers added or modified in the new
version of the file.  A file that appears with only deletions maps to an
empty hash table.  Malformed hunk headers are skipped silently."
  (let ((result (make-hash-table :test 'equal))
		(current-lines nil))
	(with-temp-buffer
	  (insert output)
	  (goto-char (point-min))
	  (while (not (eobp))
		(let ((line (buffer-substring-no-properties
					 (line-beginning-position) (line-end-position))))
		  (cond
		   ((string-match cj/--coverage-file-marker-regexp line)
			(let ((path (match-string 1 line)))
			  (setq current-lines (make-hash-table :test 'eql))
			  (puthash path current-lines result)))
		   ((string-prefix-p "+++ /dev/null" line)
			(setq current-lines nil))
		   ((and current-lines
				 (string-match cj/--coverage-hunk-header-regexp line))
			(let* ((new-start (string-to-number (match-string 2 line)))
				   (count-str (match-string 4 line))
				   (new-count (if count-str
								  (string-to-number count-str)
								1)))
			  (when (> new-count 0)
				(dotimes (i new-count)
				  (puthash (+ new-start i) t current-lines)))))))
		(forward-line 1)))
	result))

(defun cj/--coverage-changed-lines (scope &optional base)
  "Return a hash table of files to changed line numbers for SCOPE.
SCOPE is one of the symbols `working-tree', `staged', `branch-vs-main',
or `branch-vs-parent'.  For `branch-vs-parent', BASE is the ref to
compare against; if nil, falls back to the tracked upstream @{upstream}.
Signals `user-error' for any other SCOPE."
  (let ((cmd (cond
			  ((eq scope 'working-tree)
			   "git diff HEAD --unified=0")
			  ((eq scope 'staged)
			   "git diff --cached --unified=0")
			  ((eq scope 'branch-vs-main)
			   "git diff $(git merge-base HEAD main)..HEAD --unified=0")
			  ((eq scope 'branch-vs-parent)
			   (format "git diff $(git merge-base HEAD %s)..HEAD --unified=0"
					   (or base "@{upstream}")))
			  (t
			   (user-error "Unknown coverage scope: %s" scope)))))
	(cj/--coverage-parse-diff-output (shell-command-to-string cmd))))

(defun cj/--coverage-hash-keys-sorted (table)
  "Return a sorted list of TABLE's integer keys."
  (let (keys)
	(maphash (lambda (k _v) (push k keys)) table)
	(sort keys #'<)))

(defun cj/--coverage-intersect (covered changed)
  "Combine COVERED (LCOV) with CHANGED (git diff) into per-file records.
COVERED and CHANGED are each hash tables from file path to a hash table
of line numbers (as built by `cj/--coverage-parse-simplecov' and
`cj/--coverage-parse-diff-output').  Either may be nil, in which case
the result is an empty list.

Return value is a list of plists, one per entry in CHANGED, sorted by
path:
  (:path PATH
   :changed-lines LIST-OF-INTS
   :covered-lines LIST-OF-INTS   ; nil when the file isn't tracked
   :uncovered-lines LIST-OF-INTS ; nil when the file isn't tracked
   :tracked BOOL)

A file that appears in CHANGED but not in COVERED is marked as
`:tracked nil'; coverage data is unavailable for it, so no lines
can be classified as covered or uncovered."
  (unless (and covered changed)
	(setq covered (or covered (make-hash-table :test 'equal)))
	(setq changed (or changed (make-hash-table :test 'equal))))
  (let (paths records)
	(maphash (lambda (path _) (push path paths)) changed)
	(setq paths (sort paths #'string<))
	(dolist (path paths)
	  (let* ((changed-set (gethash path changed))
			 (changed-lines (cj/--coverage-hash-keys-sorted changed-set))
			 (covered-set (gethash path covered))
			 (tracked (and covered-set t))
			 covered-lines
			 uncovered-lines)
		(when tracked
		  (dolist (line changed-lines)
			(if (gethash line covered-set)
				(push line covered-lines)
			  (push line uncovered-lines)))
		  (setq covered-lines (nreverse covered-lines)
				uncovered-lines (nreverse uncovered-lines)))
		(push (list :path path
					:changed-lines changed-lines
					:covered-lines covered-lines
					:uncovered-lines uncovered-lines
					:tracked tracked)
			  records)))
	(nreverse records)))

(defun cj/--coverage-format-report (records scope-label)
  "Render RECORDS as a text report for SCOPE-LABEL.
RECORDS is the list of plists produced by `cj/--coverage-intersect'.
SCOPE-LABEL is the human-readable scope name (e.g. \"Staged\").
Returns a string ready to insert into a compilation-mode buffer.

Uncovered-line entries use the format \"<path>:<line>: uncovered\"
so `compilation-error-regexp-alist' picks them up for
`next-error' / `previous-error' navigation.

Files with an empty :changed-lines (deletion-only hunks) are
omitted from the display.  The summary counts only tracked files."
  (if (null records)
	  (format "Coverage Report — %s\n\nNo changes in this scope; nothing to report.\n"
			  scope-label)
	(let (partial fully-covered not-tracked
				  (total-covered 0)
				  (total-tracked 0))
	  (dolist (rec records)
		(let ((changed (plist-get rec :changed-lines))
			  (tracked (plist-get rec :tracked))
			  (uncovered (plist-get rec :uncovered-lines))
			  (covered (plist-get rec :covered-lines)))
		  (cond
		   ((null changed) nil)   ; deletion-only; skip
		   ((not tracked)
			(push rec not-tracked))
		   (uncovered
			(push rec partial)
			(setq total-covered (+ total-covered (length covered))
				  total-tracked (+ total-tracked (length changed))))
		   (t
			(push rec fully-covered)
			(setq total-covered (+ total-covered (length covered))
				  total-tracked (+ total-tracked (length changed)))))))
	  (setq partial (nreverse partial)
			fully-covered (nreverse fully-covered)
			not-tracked (nreverse not-tracked))
	  (with-temp-buffer
		(let* ((header (format "Coverage Report — %s" scope-label))
			   (pct (if (> total-tracked 0)
						(/ (* 100.0 total-covered) total-tracked)
					  0.0)))
		  (insert header "\n")
		  (insert (make-string (length header) ?=) "\n\n")
		  (insert (format "Summary: %d of %d changed lines covered (%.1f%%)\n\n"
						  total-covered total-tracked pct)))
		(when partial
		  (insert "Uncovered lines:\n")
		  (dolist (rec partial)
			(dolist (line (plist-get rec :uncovered-lines))
			  (insert (format "  %s:%d: uncovered\n"
							  (plist-get rec :path) line))))
		  (insert "\n"))
		(when not-tracked
		  (insert "Not tracked (coverage data unavailable):\n")
		  (dolist (rec not-tracked)
			(insert (format "  %s (%d lines changed)\n"
							(plist-get rec :path)
							(length (plist-get rec :changed-lines)))))
		  (insert "\n"))
		(when fully-covered
		  (insert "Fully covered:\n")
		  (dolist (rec fully-covered)
			(let ((cnt (length (plist-get rec :covered-lines))))
			  (insert (format "  %s (%d/%d)\n"
							  (plist-get rec :path) cnt cnt))))
		  (insert "\n"))
		(buffer-string)))))

(provide 'coverage-core)
;;; coverage-core.el ends here