diff options
| -rw-r--r-- | modules/coverage-core.el | 64 | ||||
| -rw-r--r-- | tests/test-coverage-core--changed-lines.el | 193 |
2 files changed, 257 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 diff --git a/tests/test-coverage-core--changed-lines.el b/tests/test-coverage-core--changed-lines.el new file mode 100644 index 00000000..dcf37603 --- /dev/null +++ b/tests/test-coverage-core--changed-lines.el @@ -0,0 +1,193 @@ +;;; test-coverage-core--changed-lines.el --- Tests for cj/--coverage-changed-lines -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for: +;; `cj/--coverage-parse-diff-output' (pure parser over git-diff text) +;; `cj/--coverage-changed-lines' (scope → hash table, shells to git) +;; +;; The parser takes the output of `git diff --unified=0' and returns +;; a hash table of file → set of changed (added) line numbers in the +;; new version. Hunk headers have the form: +;; @@ -<old_start>[,<old_count>] +<new_start>[,<new_count>] @@ +;; Changed lines are new_start through new_start + new_count - 1. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'coverage-core) + +;;; Fixtures + +(defconst test-coverage-diff--simple-single-file + "diff --git a/foo.el b/foo.el +index abc..def 100644 +--- a/foo.el ++++ b/foo.el +@@ -10,1 +10,3 @@ +-old line ++new line 1 ++new line 2 ++new line 3 +" + "Single-file diff with one hunk adding three lines at line 10.") + +(defconst test-coverage-diff--multiple-files + "diff --git a/a.el b/a.el +--- a/a.el ++++ b/a.el +@@ -1,0 +1,2 @@ ++line 1 ++line 2 +diff --git a/b.el b/b.el +--- a/b.el ++++ b/b.el +@@ -5,0 +6,1 @@ ++new line +" + "Two-file diff.") + +(defconst test-coverage-diff--new-file + "diff --git a/new.el b/new.el +new file mode 100644 +index 0000000..abc +--- /dev/null ++++ b/new.el +@@ -0,0 +1,3 @@ ++line 1 ++line 2 ++line 3 +" + "New file: @@ -0,0 +1,3 @@ — three lines added to a brand-new file.") + +(defconst test-coverage-diff--deletion-only + "diff --git a/gone.el b/gone.el +--- a/gone.el ++++ b/gone.el +@@ -1,3 +1,0 @@ +-removed 1 +-removed 2 +-removed 3 +" + "Deletion-only hunk: no new lines, file should map to an empty set.") + +(defconst test-coverage-diff--binary-marker + "diff --git a/image.png b/image.png +Binary files a/image.png and b/image.png differ +" + "Binary file marker: no parseable hunks.") + +(defconst test-coverage-diff--single-line-no-count + "diff --git a/foo.el b/foo.el +--- a/foo.el ++++ b/foo.el +@@ -5 +5 @@ +-old ++new +" + "Hunk without a count means a single line (count defaults to 1).") + +;;; Normal cases — parser + +(ert-deftest test-coverage-parse-diff-single-hunk-three-lines () + "Normal: one hunk adding three lines gives {10, 11, 12}." + (let* ((result (cj/--coverage-parse-diff-output + test-coverage-diff--simple-single-file)) + (lines (gethash "foo.el" result))) + (should (= 1 (hash-table-count result))) + (should (= 3 (hash-table-count lines))) + (should (gethash 10 lines)) + (should (gethash 11 lines)) + (should (gethash 12 lines)))) + +(ert-deftest test-coverage-parse-diff-multiple-files () + "Normal: two files parsed separately with their own line sets." + (let* ((result (cj/--coverage-parse-diff-output + test-coverage-diff--multiple-files)) + (a-lines (gethash "a.el" result)) + (b-lines (gethash "b.el" result))) + (should (= 2 (hash-table-count result))) + (should (= 2 (hash-table-count a-lines))) + (should (gethash 1 a-lines)) + (should (gethash 2 a-lines)) + (should (= 1 (hash-table-count b-lines))) + (should (gethash 6 b-lines)))) + +;;; Boundary cases — parser + +(ert-deftest test-coverage-parse-diff-new-file () + "Boundary: new file hunk @@ -0,0 +1,3 @@ yields lines 1-3." + (let* ((result (cj/--coverage-parse-diff-output + test-coverage-diff--new-file)) + (lines (gethash "new.el" result))) + (should (= 3 (hash-table-count lines))) + (should (gethash 1 lines)) + (should (gethash 2 lines)) + (should (gethash 3 lines)))) + +(ert-deftest test-coverage-parse-diff-deletion-only () + "Boundary: deletion-only hunk (+1,0) records the file with an empty line set." + (let* ((result (cj/--coverage-parse-diff-output + test-coverage-diff--deletion-only)) + (lines (gethash "gone.el" result))) + (should (hash-table-p lines)) + (should (= 0 (hash-table-count lines))))) + +(ert-deftest test-coverage-parse-diff-binary-file-ignored () + "Boundary: binary files have no hunks; parser doesn't crash." + (let ((result (cj/--coverage-parse-diff-output + test-coverage-diff--binary-marker))) + (should (hash-table-p result)) + (should (= 0 (hash-table-count result))))) + +(ert-deftest test-coverage-parse-diff-single-line-no-count () + "Boundary: @@ -5 +5 @@ means one line at line 5 (count defaults to 1)." + (let* ((result (cj/--coverage-parse-diff-output + test-coverage-diff--single-line-no-count)) + (lines (gethash "foo.el" result))) + (should (= 1 (hash-table-count lines))) + (should (gethash 5 lines)))) + +(ert-deftest test-coverage-parse-diff-empty-input () + "Boundary: empty string input returns an empty hash table, not nil." + (let ((result (cj/--coverage-parse-diff-output ""))) + (should (hash-table-p result)) + (should (= 0 (hash-table-count result))))) + +;;; Error cases — parser + +(ert-deftest test-coverage-parse-diff-malformed-hunk-header-skipped () + "Error: a malformed @@ line is skipped; surrounding valid hunks still parse." + (let* ((input (concat "diff --git a/foo.el b/foo.el\n" + "--- a/foo.el\n" + "+++ b/foo.el\n" + "@@ this is not a valid hunk header @@\n" + "@@ -1,0 +10,2 @@\n" + "+ok1\n" + "+ok2\n")) + (result (cj/--coverage-parse-diff-output input)) + (lines (gethash "foo.el" result))) + (should (= 2 (hash-table-count lines))) + (should (gethash 10 lines)) + (should (gethash 11 lines)))) + +;;; Smoke test — changed-lines (stubbed git invocation) + +(ert-deftest test-coverage-changed-lines-working-tree-stubbed () + "Smoke: scope dispatches, shell is stubbed, parser is applied to the result." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) test-coverage-diff--simple-single-file))) + (let* ((result (cj/--coverage-changed-lines 'working-tree)) + (lines (gethash "foo.el" result))) + (should (= 1 (hash-table-count result))) + (should (= 3 (hash-table-count lines)))))) + +(ert-deftest test-coverage-changed-lines-unknown-scope-errors () + "Error: an unknown scope symbol signals user-error." + (should-error (cj/--coverage-changed-lines 'bogus-scope) + :type 'user-error)) + +(provide 'test-coverage-core--changed-lines) +;;; test-coverage-core--changed-lines.el ends here |
