diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-22 17:03:33 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-22 17:03:33 -0500 |
| commit | 1097878bcb45a1c68ec5f9e44a727c2dd7e45725 (patch) | |
| tree | e39a9f4ca91363c9d4fbe2af2c3d04c4c708267d | |
| parent | 83ac3201023f8736c234da27a0642f21786adcfc (diff) | |
| download | dotemacs-1097878bcb45a1c68ec5f9e44a727c2dd7e45725.tar.gz dotemacs-1097878bcb45a1c68ec5f9e44a727c2dd7e45725.zip | |
feat(coverage): add cj/--coverage-parse-lcov helper
First of three pure helpers for the coverage-report command. Reads an LCOV file and returns a hash table of file to set of covered line numbers. Only the SF, DA, and end_of_record fields are interpreted. Other LCOV fields (FN, FNDA, LF, LH, BRDA) are ignored. Malformed DA lines are skipped silently so partial runs still yield usable data.
Tests cover Normal (single file, multiple files, mixed hit counts), Boundary (empty file, spaces in path, extra fields, all-zero hits), and Error (missing file, malformed DA lines).
Part of the coverage-core work per docs/design/coverage.org.
| -rw-r--r-- | modules/coverage-core.el | 58 | ||||
| -rw-r--r-- | tests/test-coverage-core--parse-lcov.el | 149 |
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 |
