diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-23 01:11:54 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-23 01:11:54 -0500 |
| commit | fe142a8d9268c36b6b8fd363e60cb587dded1602 (patch) | |
| tree | 254b06931402efe3111992ccb0322b46e528c5e9 | |
| parent | 3fa47b1a3b7dd8d3a4f00f117e6f97caf55a3c5d (diff) | |
| download | dotemacs-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.el | 2 | ||||
| -rw-r--r-- | modules/coverage-core.el | 108 | ||||
| -rw-r--r-- | tests/test-coverage-core--command.el | 90 |
3 files changed, 200 insertions, 0 deletions
@@ -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 |
