summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-23 01:11:54 -0500
committerCraig Jennings <c@cjennings.net>2026-04-23 01:11:54 -0500
commitfe142a8d9268c36b6b8fd363e60cb587dded1602 (patch)
tree254b06931402efe3111992ccb0322b46e528c5e9
parent3fa47b1a3b7dd8d3a4f00f117e6f97caf55a3c5d (diff)
downloaddotemacs-fe142a8d9268c36b6b8fd363e60cb587dded1602.tar.gz
dotemacs-fe142a8d9268c36b6b8fd363e60cb587dded1602.zip
feat(coverage): add cj/coverage-report command and F7 binding
Completes the coverage v1 user-facing path. cj/coverage-report is the interactive entry point: 1. Resolves the backend for the current project (honoring cj/coverage-backend from .dir-locals.el). 2. Prompts for a git-diff scope via completing-read (Working tree, Staged, Branch vs parent, Branch vs main). 3. Reads the cached simplecov report, intersects with the diff, renders records into a *Coverage Report* buffer. 4. If the report doesn't exist, prompts to run coverage first. With a prefix argument, re-runs regardless. The report buffer uses cj/coverage-report-mode, a compilation-mode derivative. Uncovered-line entries are formatted as path:line: uncovered so the standard gnu compilation-error-regexp-alist picks them up for next-error navigation. That means M-g n, M-g p, and C-x backtick walk through uncovered lines from any buffer without switching focus. F7 is bound to the command globally, matching the F-key layout ticket's design (F4 compile+run, F5 debug, F6 test, F7 coverage). Added to init.el: (require 'coverage-core) + (require 'coverage-elisp). Tests cover the pure scope-label helpers (label to symbol, symbol to label, roundtrip) plus a smoke test that exercises the full command with stubbed backend, stubbed completing-read, stubbed shell-command-to-string, and a prepared simplecov fixture. Coverage v1 is now functionally complete: make coverage produces the report, F7 drives the interactive flow.
-rw-r--r--init.el2
-rw-r--r--modules/coverage-core.el108
-rw-r--r--tests/test-coverage-core--command.el90
3 files changed, 200 insertions, 0 deletions
diff --git a/init.el b/init.el
index 4f5a3aa1..d13f3378 100644
--- a/init.el
+++ b/init.el
@@ -48,6 +48,8 @@
(require 'text-config) ;; text settings and functionality
(require 'undead-buffers) ;; bury rather than kill buffers you choose
(require 'browser-config) ;; browser configuration/integration
+(require 'coverage-core) ;; diff-aware coverage engine + F7 binding
+(require 'coverage-elisp) ;; elisp backend for coverage-core
;; ------------------------ User Interface Configuration -----------------------
diff --git a/modules/coverage-core.el b/modules/coverage-core.el
index bda90612..a801b1d5 100644
--- a/modules/coverage-core.el
+++ b/modules/coverage-core.el
@@ -297,5 +297,113 @@ omitted from the display. The summary counts only tracked files."
(insert "\n"))
(buffer-string)))))
+;;; --- Scope selection ---
+
+(defconst cj/--coverage-scope-alist
+ '(("Working tree — all uncommitted changes" . working-tree)
+ ("Staged — about to commit" . staged)
+ ("Branch vs parent" . branch-vs-parent)
+ ("Branch vs main" . branch-vs-main))
+ "Alist mapping human-readable scope labels to scope symbols.
+Used by `cj/--coverage-select-scope' for the `completing-read' prompt
+and by the report-buffer header to show which scope was picked.")
+
+(defun cj/--coverage-scope-from-label (label)
+ "Return the scope symbol for human-readable LABEL, or nil if unknown."
+ (cdr (assoc label cj/--coverage-scope-alist)))
+
+(defun cj/--coverage-label-from-scope (scope)
+ "Return the human-readable label for SCOPE symbol, or nil if unknown."
+ (car (rassq scope cj/--coverage-scope-alist)))
+
+(defun cj/--coverage-select-scope ()
+ "Prompt for a coverage scope via `completing-read'.
+Returns the selected scope symbol (e.g. `staged')."
+ (let* ((labels (mapcar #'car cj/--coverage-scope-alist))
+ (choice (completing-read "Coverage scope: " labels nil t)))
+ (cj/--coverage-scope-from-label choice)))
+
+;;; --- User-facing command ---
+
+(defun cj/--coverage-project-root ()
+ "Return the current project's root, or `default-directory' as fallback."
+ (or (and (fboundp 'projectile-project-root)
+ (projectile-project-root))
+ default-directory))
+
+(defun cj/--coverage-render-to-buffer (records scope)
+ "Render RECORDS for SCOPE into the coverage report buffer.
+Does the buffer setup, the insert, and switches it into
+`cj/coverage-report-mode' for compilation-mode navigation."
+ (let* ((label (cj/--coverage-label-from-scope scope))
+ (text (cj/--coverage-format-report records label))
+ (buf (get-buffer-create "*Coverage Report*")))
+ (with-current-buffer buf
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert text))
+ (cj/coverage-report-mode)
+ (goto-char (point-min)))
+ (display-buffer buf)
+ buf))
+
+(defun cj/--coverage-read-and-display (backend scope)
+ "Parse BACKEND's report file, intersect with SCOPE, display result."
+ (let* ((report-path (funcall (plist-get backend :report-path)))
+ (covered (cj/--coverage-parse-simplecov report-path))
+ (changed (cj/--coverage-changed-lines scope))
+ (records (cj/--coverage-intersect covered changed)))
+ (cj/--coverage-render-to-buffer records scope)))
+
+(defun cj/coverage-report (&optional force-rerun)
+ "Show a coverage report for in-flight changes in the current project.
+Prompts for a git-diff scope via `completing-read', reads the most
+recent coverage data, intersects with the diff, and pops a buffer
+listing covered, uncovered, and not-tracked files.
+
+With a prefix argument (FORCE-RERUN non-nil), re-runs coverage before
+displaying the report even if a recent report already exists. Without
+the prefix, a missing report triggers a y/n prompt."
+ (interactive "P")
+ (let* ((root (cj/--coverage-project-root))
+ (backend (cj/--coverage-backend-for-project
+ root cj/coverage-backend)))
+ (unless backend
+ (user-error
+ "No coverage backend for %s. Register one or set cj/coverage-backend"
+ root))
+ (let* ((scope (cj/--coverage-select-scope))
+ (report-path (funcall (plist-get backend :report-path))))
+ (cond
+ ;; Force rerun via prefix arg
+ (force-rerun
+ (funcall (plist-get backend :run)
+ (lambda (_path)
+ (cj/--coverage-read-and-display backend scope))))
+ ;; Missing report — prompt before running
+ ((not (file-exists-p report-path))
+ (if (y-or-n-p (format "No coverage report at %s. Run coverage now? "
+ report-path))
+ (funcall (plist-get backend :run)
+ (lambda (_path)
+ (cj/--coverage-read-and-display backend scope)))
+ (user-error "Coverage cancelled")))
+ ;; Otherwise, use existing data
+ (t
+ (cj/--coverage-read-and-display backend scope))))))
+
+(define-derived-mode cj/coverage-report-mode compilation-mode "Coverage"
+ "Major mode for the coverage report buffer.
+Derives from `compilation-mode' so next-error / previous-error and
+the standard navigation keys (n, p, RET, g, q) work without extra
+setup. Uncovered-line entries match compilation-mode's default
+`file:line: message' error regex."
+ (setq-local compilation-error-regexp-alist '(gnu))
+ (setq-local compilation-search-path (list (cj/--coverage-project-root))))
+
+;;; --- Global keybinding ---
+
+(keymap-global-set "<f7>" #'cj/coverage-report)
+
(provide 'coverage-core)
;;; coverage-core.el ends here
diff --git a/tests/test-coverage-core--command.el b/tests/test-coverage-core--command.el
new file mode 100644
index 00000000..d7b2c1cc
--- /dev/null
+++ b/tests/test-coverage-core--command.el
@@ -0,0 +1,90 @@
+;;; test-coverage-core--command.el --- Tests for cj/coverage-report and scope helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for:
+;; `cj/--coverage-scope-from-label' (pure label → symbol lookup)
+;; `cj/--coverage-label-from-scope' (pure symbol → label lookup)
+;; `cj/coverage-report' (one smoke test with stubbed git + prepared report)
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'coverage-core)
+
+;;; Scope label <-> symbol (pure lookups)
+
+(ert-deftest test-coverage-scope-from-label-known ()
+ "Normal: each registered label maps to its scope symbol."
+ (should (eq 'working-tree
+ (cj/--coverage-scope-from-label
+ "Working tree — all uncommitted changes")))
+ (should (eq 'staged
+ (cj/--coverage-scope-from-label "Staged — about to commit")))
+ (should (eq 'branch-vs-parent
+ (cj/--coverage-scope-from-label "Branch vs parent")))
+ (should (eq 'branch-vs-main
+ (cj/--coverage-scope-from-label "Branch vs main"))))
+
+(ert-deftest test-coverage-scope-from-label-unknown ()
+ "Boundary: unknown label returns nil."
+ (should-not (cj/--coverage-scope-from-label "bogus label"))
+ (should-not (cj/--coverage-scope-from-label "")))
+
+(ert-deftest test-coverage-label-from-scope-roundtrip ()
+ "Normal: symbol → label → symbol is an identity."
+ (dolist (sym '(working-tree staged branch-vs-parent branch-vs-main))
+ (should (eq sym (cj/--coverage-scope-from-label
+ (cj/--coverage-label-from-scope sym))))))
+
+;;; Smoke test for the interactive command
+
+(ert-deftest test-coverage-report-smoke-happy-path ()
+ "Smoke: cj/coverage-report with stubbed backend, scope, and git-diff
+populates the *Coverage Report* buffer with the expected summary and
+uncovered-line markers."
+ (let* ((tmp-root (make-temp-file "test-coverage-smoke-" t))
+ (report-file (expand-file-name "simplecov.json" tmp-root))
+ (simplecov (concat "{\"run\":{\"timestamp\":1,\"coverage\":"
+ "{\"modules/foo.el\":[null,1,0,1,0]}}}"))
+ (diff-output (concat "diff --git a/modules/foo.el b/modules/foo.el\n"
+ "--- a/modules/foo.el\n"
+ "+++ b/modules/foo.el\n"
+ "@@ -1,0 +2,4 @@\n"
+ "+added 1\n+added 2\n+added 3\n+added 4\n"))
+ (test-backend
+ (list :name 'test-backend
+ :detect (lambda (_) t)
+ :run (lambda (cb) (funcall cb report-file))
+ :report-path (lambda (&rest _) report-file)))
+ (cj/coverage-backends nil))
+ (unwind-protect
+ (progn
+ (with-temp-file report-file (insert simplecov))
+ (cj/coverage-register-backend test-backend)
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) "Staged — about to commit"))
+ ((symbol-function 'shell-command-to-string)
+ (lambda (_) diff-output))
+ ((symbol-function 'cj/--coverage-project-root)
+ (lambda () tmp-root))
+ ((symbol-function 'display-buffer) #'identity))
+ (call-interactively #'cj/coverage-report))
+ (with-current-buffer "*Coverage Report*"
+ (let ((text (buffer-substring-no-properties (point-min) (point-max))))
+ ;; Scope label appears
+ (should (string-match-p "Staged" text))
+ ;; Lines 2 and 4 hit, lines 3 and 5 uncovered (per simplecov)
+ ;; Diff covers lines 2-5
+ ;; So the summary should show 2 of 4 covered
+ (should (string-match-p "2 of 4" text))
+ ;; Uncovered line markers use compilation-friendly format
+ (should (string-match-p "modules/foo\\.el:[35]: uncovered" text)))))
+ (when (get-buffer "*Coverage Report*")
+ (kill-buffer "*Coverage Report*"))
+ (delete-directory tmp-root t))))
+
+(provide 'test-coverage-core--command)
+;;; test-coverage-core--command.el ends here