From fe142a8d9268c36b6b8fd363e60cb587dded1602 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 23 Apr 2026 01:11:54 -0500 Subject: 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. --- modules/coverage-core.el | 108 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) (limited to 'modules/coverage-core.el') 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 "" #'cj/coverage-report) + (provide 'coverage-core) ;;; coverage-core.el ends here -- cgit v1.2.3