aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/coverage-core.el58
-rw-r--r--tests/test-coverage-core--parse-lcov.el149
2 files changed, 207 insertions, 0 deletions
diff --git a/modules/coverage-core.el b/modules/coverage-core.el
new file mode 100644
index 00000000..4a3112ce
--- /dev/null
+++ b/modules/coverage-core.el
@@ -0,0 +1,58 @@
+;;; coverage-core.el --- Coverage reporting engine and backend registry -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Language-agnostic core for diff-aware coverage reporting.
+;;
+;; Reads an LCOV file, shells to git diff at a selectable scope,
+;; intersects the results, and displays a report buffer. Languages
+;; plug in via the backend registry (see `cj/coverage-backends').
+;;
+;; See docs/design/coverage.org for the design rationale.
+
+;;; Code:
+
+(defun cj/--coverage-parse-lcov (file)
+ "Parse FILE as LCOV and return a hash table of covered lines.
+Keys are source-file paths (strings). Values are hash tables whose
+keys are line numbers (integers) that had a hit count greater than
+zero. Only the SF, DA, and end_of_record fields are read; other
+LCOV fields are ignored. Malformed DA lines are skipped silently.
+Signals `user-error' if FILE does not exist."
+ (unless (file-exists-p file)
+ (user-error "LCOV file not found: %s" file))
+ (let ((result (make-hash-table :test 'equal))
+ (current-file nil)
+ (current-lines nil))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (while (not (eobp))
+ (let ((line (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position))))
+ (cond
+ ((string-prefix-p "SF:" line)
+ (setq current-file (substring line 3))
+ (setq current-lines (make-hash-table :test 'eql)))
+ ((string-prefix-p "DA:" line)
+ (when current-lines
+ (let* ((rest (substring line 3))
+ (parts (split-string rest ","))
+ (line-str (car parts))
+ (hits-str (cadr parts))
+ (line-num (and line-str (string-match-p "\\`[0-9]+\\'" line-str)
+ (string-to-number line-str)))
+ (hits (and hits-str (string-match-p "\\`[0-9]+\\'" hits-str)
+ (string-to-number hits-str))))
+ (when (and line-num hits (> hits 0))
+ (puthash line-num t current-lines)))))
+ ((string= line "end_of_record")
+ (when current-file
+ (puthash current-file current-lines result))
+ (setq current-file nil
+ current-lines nil))))
+ (forward-line 1)))
+ result))
+
+(provide 'coverage-core)
+;;; coverage-core.el ends here
diff --git a/tests/test-coverage-core--parse-lcov.el b/tests/test-coverage-core--parse-lcov.el
new file mode 100644
index 00000000..a0a800ef
--- /dev/null
+++ b/tests/test-coverage-core--parse-lcov.el
@@ -0,0 +1,149 @@
+;;; test-coverage-core--parse-lcov.el --- Tests for cj/--coverage-parse-lcov -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for `cj/--coverage-parse-lcov', the pure helper that
+;; reads an LCOV file and returns a hash table of file → set of
+;; covered line numbers.
+;;
+;; LCOV format (the subset we care about):
+;; SF:<source file>
+;; DA:<line>,<hit count>
+;; end_of_record
+;;
+;; A line counts as "covered" when its hit count is greater than zero.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'coverage-core)
+
+(defun test-coverage-parse-lcov--write-temp-lcov (content)
+ "Write CONTENT to a temp file and return its path.
+Caller is responsible for deleting the file."
+ (let ((file (make-temp-file "test-lcov-" nil ".info")))
+ (with-temp-file file
+ (insert content))
+ file))
+
+;;; Normal cases
+
+(ert-deftest test-coverage-parse-lcov-single-file-all-covered ()
+ "Normal: one file with every line hit > 0 returns all lines in the set."
+ (let* ((content "SF:/path/to/foo.el\nDA:10,1\nDA:11,2\nDA:12,3\nend_of_record\n")
+ (file (test-coverage-parse-lcov--write-temp-lcov content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-parse-lcov file))
+ (lines (gethash "/path/to/foo.el" result)))
+ (should (hash-table-p result))
+ (should (= 3 (hash-table-count lines)))
+ (should (gethash 10 lines))
+ (should (gethash 11 lines))
+ (should (gethash 12 lines)))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-lcov-multiple-files ()
+ "Normal: multiple file records in one LCOV file are both parsed."
+ (let* ((content (concat "SF:/a.el\nDA:1,1\nDA:2,1\nend_of_record\n"
+ "SF:/b.el\nDA:5,1\nend_of_record\n"))
+ (file (test-coverage-parse-lcov--write-temp-lcov content)))
+ (unwind-protect
+ (let ((result (cj/--coverage-parse-lcov file)))
+ (should (= 2 (hash-table-count result)))
+ (should (gethash "/a.el" result))
+ (should (gethash "/b.el" result)))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-lcov-mixed-hits ()
+ "Normal: lines with hit count 0 are excluded; positive counts included."
+ (let* ((content "SF:/foo.el\nDA:1,0\nDA:2,1\nDA:3,0\nDA:4,5\nend_of_record\n")
+ (file (test-coverage-parse-lcov--write-temp-lcov content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-parse-lcov file))
+ (lines (gethash "/foo.el" result)))
+ (should (= 2 (hash-table-count lines)))
+ (should-not (gethash 1 lines))
+ (should (gethash 2 lines))
+ (should-not (gethash 3 lines))
+ (should (gethash 4 lines)))
+ (delete-file file))))
+
+;;; Boundary cases
+
+(ert-deftest test-coverage-parse-lcov-empty-file ()
+ "Boundary: empty LCOV file returns an empty hash table, not nil."
+ (let ((file (test-coverage-parse-lcov--write-temp-lcov "")))
+ (unwind-protect
+ (let ((result (cj/--coverage-parse-lcov file)))
+ (should (hash-table-p result))
+ (should (= 0 (hash-table-count result))))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-lcov-file-with-spaces-in-path ()
+ "Boundary: filename with spaces is parsed as one key."
+ (let* ((content "SF:/my path/with spaces.el\nDA:1,1\nend_of_record\n")
+ (file (test-coverage-parse-lcov--write-temp-lcov content)))
+ (unwind-protect
+ (let ((result (cj/--coverage-parse-lcov file)))
+ (should (gethash "/my path/with spaces.el" result)))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-lcov-extra-lcov-fields-ignored ()
+ "Boundary: LF/LH/BRDA/FN fields don't break parsing; only DA matters."
+ (let* ((content (concat "SF:/foo.el\n"
+ "FN:10,some-function\n"
+ "FNDA:1,some-function\n"
+ "DA:10,1\n"
+ "DA:11,1\n"
+ "LF:2\n"
+ "LH:2\n"
+ "BRDA:10,0,0,1\n"
+ "end_of_record\n"))
+ (file (test-coverage-parse-lcov--write-temp-lcov content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-parse-lcov file))
+ (lines (gethash "/foo.el" result)))
+ (should (= 2 (hash-table-count lines)))
+ (should (gethash 10 lines))
+ (should (gethash 11 lines)))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-lcov-all-zero-hits ()
+ "Boundary: file with no covered lines returns an empty set for that file."
+ (let* ((content "SF:/foo.el\nDA:1,0\nDA:2,0\nend_of_record\n")
+ (file (test-coverage-parse-lcov--write-temp-lcov content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-parse-lcov file))
+ (lines (gethash "/foo.el" result)))
+ (should (hash-table-p lines))
+ (should (= 0 (hash-table-count lines))))
+ (delete-file file))))
+
+;;; Error cases
+
+(ert-deftest test-coverage-parse-lcov-missing-file-errors ()
+ "Error: nonexistent file signals a user-error naming the path."
+ (should-error (cj/--coverage-parse-lcov "/nonexistent/path/xyz.info")
+ :type 'user-error))
+
+(ert-deftest test-coverage-parse-lcov-malformed-da-line-skipped ()
+ "Error: malformed DA lines are skipped; parsing continues on the rest."
+ (let* ((content (concat "SF:/foo.el\n"
+ "DA:not-a-number,1\n"
+ "DA:10,1\n"
+ "DA:\n"
+ "DA:11,notanumber\n"
+ "DA:12,2\n"
+ "end_of_record\n"))
+ (file (test-coverage-parse-lcov--write-temp-lcov content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-parse-lcov file))
+ (lines (gethash "/foo.el" result)))
+ (should (= 2 (hash-table-count lines)))
+ (should (gethash 10 lines))
+ (should (gethash 12 lines)))
+ (delete-file file))))
+
+(provide 'test-coverage-core--parse-lcov)
+;;; test-coverage-core--parse-lcov.el ends here