From 1ba475cb0cf7076a284b13d808029309d13ea08c Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 11 Jul 2026 14:29:02 -0500 Subject: fix(custom-line-paragraph): correct paren-jump, line-join, and duplicate This module carried three editing bugs. cj/jump-to-matching-paren, with point on a closing delimiter, ran backward-sexp from there and landed on the last inner sexp instead of the opener. It now steps past the closer first so backward-sexp spans the whole expression, and restores point when the delimiter is unmatched. Four tests that asserted the last-inner-sexp landings are corrected to the true matching opener. cj/join-line-or-region, without a region, added a newline unconditionally after joining, which left a stray blank line when joining a line in the middle of the buffer. It now adds the newline only at end of buffer. cj/duplicate-line-or-region duplicated a stray empty line for a region ending at beginning-of-line and split the line for a region ending mid-line. I normalized the bounds to the whole lines the region touches and insert that block once. --- modules/custom-line-paragraph.el | 61 ++++++++++++++-------- ...stom-line-paragraph-duplicate-line-or-region.el | 34 ++++++++++++ ...st-custom-line-paragraph-join-line-or-region.el | 18 ++++++- ...custom-line-paragraph-jump-to-matching-paren.el | 24 ++++----- 4 files changed, 102 insertions(+), 35 deletions(-) diff --git a/modules/custom-line-paragraph.el b/modules/custom-line-paragraph.el index d29d4125..5d96fe41 100644 --- a/modules/custom-line-paragraph.el +++ b/modules/custom-line-paragraph.el @@ -46,7 +46,10 @@ (when (> (line-number-at-pos) 1) (join-line)) (end-of-line) - (newline))) + ;; Only add a newline at end of buffer. Doing it unconditionally left a + ;; stray blank line when joining a line in the middle of the buffer. + (when (eobp) + (newline)))) (defun cj/join-paragraph () "Join all lines in the current paragraph using `cj/join-line-or-region'." @@ -67,18 +70,27 @@ produce malformed output silently." (> (length comment-start) 0)))) (user-error "Cannot comment in %s: no comment syntax defined" major-mode)) - (let* ((b (if (region-active-p) (region-beginning) (line-beginning-position))) - (e (if (region-active-p) (region-end) (line-end-position))) - (lines (split-string (buffer-substring-no-properties b e) "\n"))) + ;; Normalize the bounds to whole lines: extend to the start of the first + ;; line and the end of the last line the region touches. The old open-line + ;; loop mishandled a region ending mid-line or at beginning-of-line, either + ;; splitting a line or duplicating a stray empty line. + (let* ((rb (if (region-active-p) (region-beginning) (point))) + (re (if (region-active-p) (region-end) (point))) + (beg (save-excursion (goto-char rb) (line-beginning-position))) + (end (save-excursion + (goto-char re) + ;; A region ending exactly at beginning-of-line does not + ;; include that line, so step back to the previous line's end. + (when (and (> re rb) (bolp)) + (backward-char)) + (line-end-position))) + (text (buffer-substring-no-properties beg end))) (save-excursion - (goto-char e) - (dolist (line lines) - (open-line 1) - (forward-line 1) - (insert line) - ;; If the COMMENT prefix argument is non-nil, comment the inserted text - (when comment - (comment-region (line-beginning-position) (line-end-position))))))) + (goto-char end) + (insert "\n" text) + ;; Comment the freshly-inserted copy when the COMMENT prefix arg is set. + (when comment + (comment-region (1+ end) (point)))))) (defun cj/remove-duplicate-lines-region-or-buffer () "Remove duplicate lines in the region or buffer, keeping the first occurrence. @@ -175,9 +187,9 @@ If not on a delimiter, show a message. Respects the current syntax table." (cb (char-before)) ;; Check if on opening paren (open-p (and ca (eq (char-syntax ca) ?\())) - ;; Check if on or just after closing paren - (close-p (or (and ca (eq (char-syntax ca) ?\))) - (and cb (eq (char-syntax cb) ?\)))))) + ;; On a closing paren (point sits on it) vs just after one. + (on-close-p (and ca (eq (char-syntax ca) ?\)))) + (after-close-p (and cb (eq (char-syntax cb) ?\))))) (cond ;; Jump forward from opening (open-p @@ -185,12 +197,19 @@ If not on a delimiter, show a message. Respects the current syntax table." (forward-sexp) (scan-error (message "No matching delimiter: %s" (error-message-string err))))) - ;; Jump backward from closing - (close-p - (condition-case err - (backward-sexp) - (scan-error - (message "No matching delimiter: %s" (error-message-string err))))) + ;; Jump backward from closing to its matching opener. When point is ON + ;; the closer, step past it first so `backward-sexp' spans the whole + ;; expression to the opener rather than the last inner sexp. Restore + ;; point if the delimiter is unmatched. + ((or on-close-p after-close-p) + (let ((start (point))) + (condition-case err + (progn + (when on-close-p (forward-char)) + (backward-sexp)) + (scan-error + (goto-char start) + (message "No matching delimiter: %s" (error-message-string err)))))) ;; Not on delimiter (t (message "Point is not on a delimiter."))))) diff --git a/tests/test-custom-line-paragraph-duplicate-line-or-region.el b/tests/test-custom-line-paragraph-duplicate-line-or-region.el index 84f5bc2d..9e501086 100644 --- a/tests/test-custom-line-paragraph-duplicate-line-or-region.el +++ b/tests/test-custom-line-paragraph-duplicate-line-or-region.el @@ -327,6 +327,40 @@ (should (> (length (buffer-string)) (length "line one\nline two\nline three")))) (test-duplicate-line-or-region-teardown))) +(ert-deftest test-duplicate-line-or-region-mid-line-bounds-duplicate-whole-lines () + "A region ending mid-line duplicates every whole line it touches, no splits. +The old open-line loop split the mid-line-ending line instead." + (test-duplicate-line-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "aaa\nbbb\nccc") + (transient-mark-mode 1) + (goto-char (point-min)) + (forward-char 1) ; mid first line + (set-mark (point)) + (forward-line 1) + (forward-char 2) ; mid second line + (activate-mark) + (cj/duplicate-line-or-region) + (should (string= "aaa\nbbb\naaa\nbbb\nccc" (buffer-string)))) + (test-duplicate-line-or-region-teardown))) + +(ert-deftest test-duplicate-line-or-region-ends-at-bol-no-extra-empty-line () + "A region ending at beginning-of-line duplicates only the fully-included lines. +The old open-line loop duplicated a stray empty line here." + (test-duplicate-line-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "aaa\nbbb\nccc") + (transient-mark-mode 1) + (goto-char (point-min)) + (set-mark (point)) + (forward-line 2) ; region "aaa\nbbb\n", ends at bol of ccc + (activate-mark) + (cj/duplicate-line-or-region) + (should (string= "aaa\nbbb\naaa\nbbb\nccc" (buffer-string)))) + (test-duplicate-line-or-region-teardown))) + (ert-deftest test-duplicate-line-or-region-trailing-whitespace () "Should preserve trailing whitespace." (test-duplicate-line-or-region-setup) diff --git a/tests/test-custom-line-paragraph-join-line-or-region.el b/tests/test-custom-line-paragraph-join-line-or-region.el index f8738910..5d421683 100644 --- a/tests/test-custom-line-paragraph-join-line-or-region.el +++ b/tests/test-custom-line-paragraph-join-line-or-region.el @@ -62,8 +62,8 @@ (should (string-match-p "line one line two" (buffer-string)))) (test-join-line-or-region-teardown))) -(ert-deftest test-join-line-or-region-no-region-adds-newline-after-join () - "Without region, should add newline after joining." +(ert-deftest test-join-line-or-region-no-region-adds-newline-at-end-of-buffer () + "Without region, joining the last line adds a trailing newline at end of buffer." (test-join-line-or-region-setup) (unwind-protect (with-temp-buffer @@ -73,6 +73,20 @@ (should (string-suffix-p "\n" (buffer-string)))) (test-join-line-or-region-teardown))) +(ert-deftest test-join-line-or-region-no-region-mid-buffer-no-blank-line () + "Without region, joining a non-last line must not insert a blank line. +The trailing newline belongs only at end of buffer; adding it unconditionally +left a stray blank line between the joined line and the rest of the buffer." + (test-join-line-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "line one\nline two\nline three") + (goto-char (point-min)) + (forward-line 1) ; point on "line two", not the last line + (cj/join-line-or-region) + (should (string= "line one line two\nline three" (buffer-string)))) + (test-join-line-or-region-teardown))) + (ert-deftest test-join-line-or-region-with-region-joins-all-lines () "With region, should join all lines in region." (test-join-line-or-region-setup) diff --git a/tests/test-custom-line-paragraph-jump-to-matching-paren.el b/tests/test-custom-line-paragraph-jump-to-matching-paren.el index 31853da6..bd24faed 100644 --- a/tests/test-custom-line-paragraph-jump-to-matching-paren.el +++ b/tests/test-custom-line-paragraph-jump-to-matching-paren.el @@ -83,11 +83,11 @@ POINT-POSITION is 1-indexed (1 = first character)." ;;; Normal Cases - Backward Jump (Closing to Opening) (ert-deftest test-jump-paren-backward-simple () - "Should jump backward from closing paren to opening paren." + "Should jump from a closing paren to its matching opening paren." ;; Text: "(hello)" ;; Start at position 7 (on closing paren) - ;; Should end at position 2 (after opening paren) - (should (= 2 (test-jump-to-matching-paren "(hello)" 7)))) + ;; Should end at position 1 (the matching opening paren) + (should (= 1 (test-jump-to-matching-paren "(hello)" 7)))) (ert-deftest test-jump-paren-backward-nested () "Should jump backward over nested parens from after outer closing." @@ -97,11 +97,11 @@ POINT-POSITION is 1-indexed (1 = first character)." (should (= 1 (test-jump-to-matching-paren "(foo (bar))" 12)))) (ert-deftest test-jump-paren-backward-inner-nested () - "Should jump backward from inner closing paren." + "Should jump from an inner closing paren to its matching inner opener." ;; Text: "(foo (bar))" ;; Start at position 10 (on inner closing paren) - ;; Should end at position 7 (after inner opening paren) - (should (= 7 (test-jump-to-matching-paren "(foo (bar))" 10)))) + ;; Should end at position 6 (the matching inner opening paren) + (should (= 6 (test-jump-to-matching-paren "(foo (bar))" 10)))) (ert-deftest test-jump-bracket-backward () "Should jump backward from after closing bracket." @@ -145,11 +145,11 @@ POINT-POSITION is 1-indexed (1 = first character)." (should (= 1 (test-jump-to-matching-paren "(hello" 1)))) (ert-deftest test-jump-paren-unmatched-closing () - "Should move to beginning from unmatched closing paren." + "Should stay put on an unmatched closing paren (no matching opener)." ;; Text: "hello)" ;; Start at position 6 (on closing paren with no opening) - ;; backward-sexp with unmatched closing paren goes to beginning - (should (= 1 (test-jump-to-matching-paren "hello)" 6)))) + ;; There is no matching opener, so point is restored and stays at 6 + (should (= 6 (test-jump-to-matching-paren "hello)" 6)))) ;;; Boundary Cases - Empty Delimiters @@ -161,11 +161,11 @@ POINT-POSITION is 1-indexed (1 = first character)." (should (= 3 (test-jump-to-matching-paren "()" 1)))) (ert-deftest test-jump-paren-empty-backward () - "Should stay put when on closing paren of empty parens." + "Should jump from the closing paren of empty parens to its opener." ;; Text: "()" ;; Start at position 2 (on closing paren) - ;; backward-sexp from closing of empty parens gives an error, so stays at 2 - (should (= 2 (test-jump-to-matching-paren "()" 2)))) + ;; Should end at position 1 (the matching opening paren) + (should (= 1 (test-jump-to-matching-paren "()" 2)))) ;;; Boundary Cases - Multiple Delimiter Types -- cgit v1.2.3