From 7e038cce93981e2b78bf6e160694d81cfa5dda67 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 26 Oct 2025 01:24:42 -0500 Subject: fix+test:custom-line-paragraph:infinite loop remove-lines-containing - Add guard clause to handle empty string input gracefully (would cause infinite loop) - Add 33 comprehensive tests covering normal cases, boundary cases, and error cases - Tests verify Unicode support, regex character escaping, region vs buffer operations - All 33 tests passing --- modules/custom-line-paragraph.el | 44 +- ...ustom-line-paragraph-remove-lines-containing.el | 456 +++++++++++++++++++++ 2 files changed, 479 insertions(+), 21 deletions(-) create mode 100644 tests/test-custom-line-paragraph-remove-lines-containing.el diff --git a/modules/custom-line-paragraph.el b/modules/custom-line-paragraph.el index 811f2a4b..f33d6b55 100644 --- a/modules/custom-line-paragraph.el +++ b/modules/custom-line-paragraph.el @@ -82,27 +82,29 @@ buffer." If region is active, operate only on the region, otherwise on entire buffer. The operation is undoable." (interactive "sRemove lines containing: ") - (save-excursion - (save-restriction - (let ((region-active (use-region-p)) - (count 0)) - (when region-active - (narrow-to-region (region-beginning) (region-end))) - (goto-char (point-min)) - ;; Count lines before deletion - (while (re-search-forward (regexp-quote text) nil t) - (setq count (1+ count)) - (beginning-of-line) - (forward-line)) - ;; Go back and delete - (goto-char (point-min)) - (delete-matching-lines (regexp-quote text)) - ;; Report what was done - (message "Removed %d line%s containing '%s' from %s" - count - (if (= count 1) "" "s") - text - (if region-active "region" "buffer")))))) + (if (string-empty-p text) + (message "Empty search string - nothing to remove") + (save-excursion + (save-restriction + (let ((region-active (use-region-p)) + (count 0)) + (when region-active + (narrow-to-region (region-beginning) (region-end))) + (goto-char (point-min)) + ;; Count lines before deletion + (while (re-search-forward (regexp-quote text) nil t) + (setq count (1+ count)) + (beginning-of-line) + (forward-line)) + ;; Go back and delete + (goto-char (point-min)) + (delete-matching-lines (regexp-quote text)) + ;; Report what was done + (message "Removed %d line%s containing '%s' from %s" + count + (if (= count 1) "" "s") + text + (if region-active "region" "buffer"))))))) (defun cj/underscore-line () "Underline the current line by inserting a row of characters below it. diff --git a/tests/test-custom-line-paragraph-remove-lines-containing.el b/tests/test-custom-line-paragraph-remove-lines-containing.el new file mode 100644 index 00000000..61fab89c --- /dev/null +++ b/tests/test-custom-line-paragraph-remove-lines-containing.el @@ -0,0 +1,456 @@ +;;; test-custom-line-paragraph-remove-lines-containing.el --- Tests for cj/remove-lines-containing -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/remove-lines-containing function from custom-line-paragraph.el +;; +;; This function removes all lines containing TEXT. +;; If region is active, operate only on the region, otherwise on entire buffer. +;; The operation is undoable and reports the count of removed lines. +;; +;; The function uses (regexp-quote text) to treat special regex characters literally. +;; +;; IMPORTANT NOTE ON REGION ACTIVATION IN BATCH MODE: +;; When testing functions that use (use-region-p) in batch mode, you must +;; explicitly activate the region. Unlike interactive Emacs, batch mode does +;; not automatically activate regions when you set mark and point. + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Stub expand-region package +(provide 'expand-region) + +;; Now load the actual production module +(require 'custom-line-paragraph) + +;;; Setup and Teardown + +(defun test-remove-lines-containing-setup () + "Setup for remove-lines-containing tests." + (cj/create-test-base-dir)) + +(defun test-remove-lines-containing-teardown () + "Teardown for remove-lines-containing tests." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-remove-lines-containing-single-match () + "Should remove single line containing text." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "line one\nline two\nline three") + (cj/remove-lines-containing "two") + (should-not (string-match-p "two" (buffer-string))) + (should (string-match-p "one" (buffer-string))) + (should (string-match-p "three" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-multiple-matches () + "Should remove multiple lines containing text." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "alpha test\nbeta\ngamma test\ndelta") + (cj/remove-lines-containing "test") + (should-not (string-match-p "alpha" (buffer-string))) + (should-not (string-match-p "gamma" (buffer-string))) + (should (string-match-p "beta" (buffer-string))) + (should (string-match-p "delta" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-preserve-non-matching () + "Should preserve lines not containing text." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "keep this\nremove BAD this\nkeep that\nremove BAD that") + (cj/remove-lines-containing "BAD") + (should (string= "keep this\nkeep that\n" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-region-only () + "Should only affect active region." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "before target\ntarget middle\ntarget end\nafter") + (goto-char (point-min)) + (forward-line 1) + (set-mark (point)) + (forward-line 2) + (transient-mark-mode 1) + (activate-mark) + (cj/remove-lines-containing "target") + ;; Should keep "before target" and "after" + (should (string-match-p "before target" (buffer-string))) + (should (string-match-p "after" (buffer-string))) + ;; Should remove middle and end + (should-not (string-match-p "target middle" (buffer-string))) + (should-not (string-match-p "target end" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-whole-buffer () + "Should operate on entire buffer when no region active." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "one X\ntwo\nthree X\nfour") + (cj/remove-lines-containing "X") + (should (string= "two\nfour" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-partial-match () + "Should match text appearing anywhere in line." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "prefix MATCH suffix\nno match here\nMATCH at start\nat end MATCH") + (cj/remove-lines-containing "MATCH") + (should (string= "no match here\n" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-multiple-occurrences-per-line () + "Should remove line with text appearing multiple times." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "FOO and FOO and FOO\nbar\nFOO again") + (cj/remove-lines-containing "FOO") + (should (string= "bar\n" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-no-matches () + "Should leave buffer unchanged when text not found." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "line one\nline two\nline three") + (let ((original (buffer-string))) + (cj/remove-lines-containing "NOTFOUND") + (should (string= original (buffer-string))))) + (test-remove-lines-containing-teardown))) + +;;; Boundary Cases + +(ert-deftest test-remove-lines-containing-empty-buffer () + "Should handle empty buffer gracefully." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (cj/remove-lines-containing "anything") + (should (string= "" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-single-line-with-match () + "Should remove only line when it matches." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "only line with TARGET") + (cj/remove-lines-containing "TARGET") + (should (string= "" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-single-line-without-match () + "Should keep only line when it doesn't match." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "only line") + (cj/remove-lines-containing "NOTHERE") + (should (string= "only line" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-text-at-beginning () + "Should match text at beginning of line." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "TARGET at start\nmiddle TARGET middle\nkeep this") + (cj/remove-lines-containing "TARGET") + (should (string= "keep this" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-text-at-end () + "Should match text at end of line." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "at end TARGET\nkeep this\nanother TARGET") + (cj/remove-lines-containing "TARGET") + (should (string= "keep this\n" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-text-in-middle () + "Should match text in middle of line." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "prefix TARGET suffix\nkeep\nanother TARGET here") + (cj/remove-lines-containing "TARGET") + (should (string= "keep\n" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-empty-string () + "Should handle empty string gracefully without hanging." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "line one\nline two\nline three") + (let ((original (buffer-string))) + ;; Should not hang or remove anything + (cj/remove-lines-containing "") + ;; Buffer should be unchanged + (should (string= original (buffer-string))))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-whitespace-only-text () + "Should remove lines with specific whitespace." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "has double space\nsingle space\nhas double space again") + (cj/remove-lines-containing " ") + (should (string= "single space\n" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-very-long-line () + "Should handle very long lines (5000+ chars)." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (let ((long-line (concat (make-string 2500 ?x) "TARGET" (make-string 2500 ?y)))) + (insert long-line "\nshort line\n" long-line) + (cj/remove-lines-containing "TARGET") + (should (string= "short line\n" (buffer-string))))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-unicode-emoji () + "Should handle Unicode and emoji text." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "hello 👋 world\nno emoji\nbye 👋 friend") + (cj/remove-lines-containing "👋") + (should (string= "no emoji\n" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-special-regex-chars () + "Should treat regex special characters literally (regexp-quote)." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "line with .\nanother line\nline with * here\nkeep") + (cj/remove-lines-containing ".") + ;; Should remove only the line with literal ".", not all lines (which . would match) + (should-not (string-match-p "line with \\." (buffer-string))) + (should (string-match-p "another line" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-case-sensitive () + "Should perform case-sensitive matching." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (let ((case-fold-search nil)) ; Ensure case-sensitive + (insert "Line with Target\nLine with target\nLine with TARGET") + (cj/remove-lines-containing "target") + ;; Only lowercase "target" should match + (should (string-match-p "Target" (buffer-string))) + (should (string-match-p "TARGET" (buffer-string))))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-all-lines-match () + "Should remove all lines when every line contains text." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "X one\nX two\nX three") + (cj/remove-lines-containing "X") + (should (string= "" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-first-line-matches () + "Should handle match at buffer start." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "REMOVE first\nkeep second\nkeep third") + (cj/remove-lines-containing "REMOVE") + (should (string= "keep second\nkeep third" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-last-line-matches () + "Should handle match at buffer end." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "keep first\nkeep second\nREMOVE last") + (cj/remove-lines-containing "REMOVE") + (should (string= "keep first\nkeep second\n" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-alternating-matches () + "Should handle alternating matching lines." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "keep\nREMOVE\nkeep\nREMOVE\nkeep") + (cj/remove-lines-containing "REMOVE") + (should (string= "keep\nkeep\nkeep" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-narrowed-buffer () + "Should respect buffer narrowing (save-restriction)." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "before TARGET\nmiddle TARGET\nend TARGET\nafter") + (goto-char (point-min)) + (forward-line 1) + (let ((beg (point))) + (forward-line 2) + (narrow-to-region beg (point)) + (cj/remove-lines-containing "TARGET") + (widen) + ;; Should keep "before TARGET" and "after" + (should (string-match-p "before TARGET" (buffer-string))) + (should (string-match-p "after" (buffer-string))) + ;; Should remove middle and end + (should-not (string-match-p "middle TARGET" (buffer-string))) + (should-not (string-match-p "end TARGET" (buffer-string))))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-backwards-region () + "Should handle backwards region (mark after point)." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "keep\nREMOVE\nREMOVE\nkeep") + (goto-char (point-max)) + (set-mark (point)) + (goto-char (point-min)) + (forward-line 1) + (transient-mark-mode 1) + (activate-mark) + (cj/remove-lines-containing "REMOVE") + ;; Should work same as forward region + (should (= 2 (how-many "keep" (point-min) (point-max))))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-entire-buffer-as-region () + "Should handle entire buffer selected as region." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "keep\nREMOVE\nkeep\nREMOVE") + (transient-mark-mode 1) + (mark-whole-buffer) + (activate-mark) + (cj/remove-lines-containing "REMOVE") + (should (string= "keep\nkeep\n" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-tab-characters () + "Should match lines with tab characters." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "line\twith\ttab\nno tabs here\nanother\ttab") + (cj/remove-lines-containing "\t") + (should (string= "no tabs here\n" (buffer-string)))) + (test-remove-lines-containing-teardown))) + +;;; Error Cases + +(ert-deftest test-remove-lines-containing-read-only-buffer () + "Should error when attempting to modify read-only buffer." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "line to remove\nline to keep") + (read-only-mode 1) + (should-error (cj/remove-lines-containing "remove"))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-buffer-modified-flag () + "Should set buffer modified flag when lines removed." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "REMOVE this\nkeep this") + (set-buffer-modified-p nil) + (cj/remove-lines-containing "REMOVE") + (should (buffer-modified-p))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-undo-behavior () + "Should support undo after removing lines." + (test-remove-lines-containing-setup) + (unwind-protect + (let* ((temp-file (expand-file-name "test-undo-rmlines.txt" cj/test-base-dir)) + (original-content "REMOVE this\nkeep this")) + ;; Create file with initial content + (with-temp-file temp-file + (insert original-content)) + ;; Open file and test undo + (find-file temp-file) + (buffer-enable-undo) + ;; Establish undo history + (goto-char (point-min)) + (insert " ") + (delete-char -1) + (undo-boundary) + (let ((before-remove (buffer-string))) + (cj/remove-lines-containing "REMOVE") + (undo-boundary) + (let ((after-remove (buffer-string))) + (should-not (string= before-remove after-remove)) + (undo) + (should (string= before-remove (buffer-string))))) + (kill-buffer (current-buffer))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-cursor-position-preserved () + "Should preserve cursor position (save-excursion)." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "line one\nREMOVE\nline three") + (goto-char (point-min)) + (forward-char 5) ; Position in middle of first line + (let ((original-pos (point))) + (cj/remove-lines-containing "REMOVE") + (should (= (point) original-pos)))) + (test-remove-lines-containing-teardown))) + +(ert-deftest test-remove-lines-containing-widen-after-narrowing () + "Should restore narrowing state (save-restriction)." + (test-remove-lines-containing-setup) + (unwind-protect + (with-temp-buffer + (insert "line one\nREMOVE\nline three\nline four") + (goto-char (point-min)) + (forward-line 1) + (let ((beg (point))) + (forward-line 2) + (narrow-to-region beg (point)) + (cj/remove-lines-containing "REMOVE") + ;; After function, narrowing should be restored + (should (= (point-min) beg)) + (should (< (point-max) (buffer-size))))) + (test-remove-lines-containing-teardown))) + +(provide 'test-custom-line-paragraph-remove-lines-containing) +;;; test-custom-line-paragraph-remove-lines-containing.el ends here -- cgit v1.2.3