From 759676fcd0a9833dffe4d42b90b36c228ec34861 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 22 Apr 2026 17:07:07 -0500 Subject: 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. --- modules/coverage-core.el | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) (limited to 'modules') 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/\" 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 -- cgit v1.2.3