aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-22 17:07:07 -0500
committerCraig Jennings <c@cjennings.net>2026-04-22 17:07:07 -0500
commit759676fcd0a9833dffe4d42b90b36c228ec34861 (patch)
tree93fe62d3696612a5690b508494274b0d524b172b /modules
parent1097878bcb45a1c68ec5f9e44a727c2dd7e45725 (diff)
downloaddotemacs-759676fcd0a9833dffe4d42b90b36c228ec34861.tar.gz
dotemacs-759676fcd0a9833dffe4d42b90b36c228ec34861.zip
feat(coverage): add changed-lines helper and diff parser
Second of three pure helpers for the coverage-report command. cj/--coverage-parse-diff-output is pure. It takes a git unified-diff string and returns a hash table of file to set of added or modified line numbers (based on the +new_start,new_count hunk headers). Files with deletion-only hunks appear in the result with an empty set, so reporters can distinguish "coverage not tracked" from "no changes touched this file." cj/--coverage-changed-lines wraps that parser with scope dispatch. Scopes are working-tree, staged, branch-vs-main, and branch-vs-parent. Branch-vs-parent takes an optional BASE arg; if omitted, falls back to @{upstream}. Unknown scopes signal user-error. Tests cover Normal (single hunk, multiple files), Boundary (new file via @@ -0,0, deletion-only, binary markers, single-line hunks without a count, empty input), and Error (malformed hunk headers skipped; unknown scope). Git invocation is stubbed via cl-letf in the smoke test so the parser logic is exercised without shelling out. Part of the coverage-core work per docs/design/coverage.org.
Diffstat (limited to 'modules')
-rw-r--r--modules/coverage-core.el64
1 files changed, 64 insertions, 0 deletions
diff --git a/modules/coverage-core.el b/modules/coverage-core.el
index 4a3112ce..a2b03817 100644
--- a/modules/coverage-core.el
+++ b/modules/coverage-core.el
@@ -54,5 +54,69 @@ Signals `user-error' if FILE does not exist."
(forward-line 1)))
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))))
+
(provide 'coverage-core)
;;; coverage-core.el ends here