summaryrefslogtreecommitdiff
path: root/modules/custom-text-enclose.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-10-26 22:14:33 -0500
committerCraig Jennings <c@cjennings.net>2025-10-26 22:14:33 -0500
commitd76d1aac7a77434c4a7ecdf5af855572c223b22e (patch)
tree1b6e3d119d70d1719afa54db8e6500ab033b9b7d /modules/custom-text-enclose.el
parenta225b8d96c0bc17502475ae49ce96c0c50a63aeb (diff)
feat:text-enclose): Enhance text enclosure: new functions and tests
Add line manipulation utilities: `append-to-lines`, `prepend-to-lines`, `indent-lines`, and `dedent-lines`. Add tests for these functions and improve existing text wrapping functionalities with `wrap`, `unwrap`, and `surround` implementations. Extend keymap for easier access to these functions.
Diffstat (limited to 'modules/custom-text-enclose.el')
-rw-r--r--modules/custom-text-enclose.el295
1 files changed, 243 insertions, 52 deletions
diff --git a/modules/custom-text-enclose.el b/modules/custom-text-enclose.el
index 514419cd..ccacdd2d 100644
--- a/modules/custom-text-enclose.el
+++ b/modules/custom-text-enclose.el
@@ -2,78 +2,269 @@
;;; Commentary:
-;; This module provides functions to surround words or regions with custom strings, and to append or prepend text to lines.
+;; Text enclosure utilities for wrapping and line manipulation.
+;;
+;; Wrapping functions:
+;; - surround-word-or-region - wrap text with same delimiter on both sides
+;; - wrap-word-or-region - wrap with different opening/closing delimiters
+;; - unwrap-word-or-region - remove surrounding delimiters
+;;
+;; Line manipulation:
+;; - append-to-lines - add suffix to each line
+;; - prepend-to-lines - add prefix to each line
+;; - indent-lines - add leading whitespace (spaces or tabs)
+;; - dedent-lines - remove leading whitespace
+;;
+;; Most functions work on region or entire buffer when no region is active.
+;;
+;; Bound to keymap prefix C-; s
-;; It includes three main functions:
-;; - surround word or region with a user-specified string
-;; - append text to the end of lines
-;; - prepend text to the beginning of lines
+;;; Code:
-;; All functions work on both the active region and the entire buffer when no region is selected.
+;; cj/custom-keymap defined in keybindings.el
+(eval-when-compile (defvar cj/custom-keymap))
-;; Bound to keymap prefix C-; s
+(defun cj/--surround (text surround-string)
+ "Internal implementation: Surround TEXT with SURROUND-STRING.
+TEXT is the string to be surrounded.
+SURROUND-STRING is prepended and appended to TEXT.
+Returns the surrounded text without modifying the buffer."
+ (concat surround-string text surround-string))
-;;; Code:
+(defun cj/--wrap (text opening closing)
+ "Internal implementation: Wrap TEXT with OPENING and CLOSING strings.
+TEXT is the string to be wrapped.
+OPENING is prepended to TEXT.
+CLOSING is appended to TEXT.
+Returns the wrapped text without modifying the buffer."
+ (concat opening text closing))
(defun cj/surround-word-or-region ()
- "Surround the word at point or active region with a string read from the minibuffer."
+ "Surround the word at point or active region with a string.
+The surround string is read from the minibuffer."
(interactive)
(let ((str (read-string "Surround with: "))
(regionp (use-region-p)))
- (save-excursion
- (if regionp
- (let ((beg (region-beginning))
- (end (region-end)))
- (goto-char end)
- (insert str)
- (goto-char beg)
- (insert str))
- (if (thing-at-point 'word)
- (let ((bounds (bounds-of-thing-at-point 'word)))
- (goto-char (cdr bounds))
- (insert str)
- (goto-char (car bounds))
- (insert str))
- (message "Can't insert around. No word at point and no region selected."))))))
+ (if regionp
+ (let ((beg (region-beginning))
+ (end (region-end))
+ (text (buffer-substring (region-beginning) (region-end))))
+ (delete-region beg end)
+ (goto-char beg)
+ (insert (cj/--surround text str)))
+ (if (thing-at-point 'word)
+ (let* ((bounds (bounds-of-thing-at-point 'word))
+ (text (buffer-substring (car bounds) (cdr bounds))))
+ (delete-region (car bounds) (cdr bounds))
+ (goto-char (car bounds))
+ (insert (cj/--surround text str)))
+ (message "Can't insert around. No word at point and no region selected.")))))
+
+(defun cj/wrap-word-or-region ()
+ "Wrap the word at point or active region with different opening/closing strings.
+The opening and closing strings are read from the minibuffer."
+ (interactive)
+ (let ((opening (read-string "Opening: "))
+ (closing (read-string "Closing: "))
+ (regionp (use-region-p)))
+ (if regionp
+ (let ((beg (region-beginning))
+ (end (region-end))
+ (text (buffer-substring (region-beginning) (region-end))))
+ (delete-region beg end)
+ (goto-char beg)
+ (insert (cj/--wrap text opening closing)))
+ (if (thing-at-point 'word)
+ (let* ((bounds (bounds-of-thing-at-point 'word))
+ (text (buffer-substring (car bounds) (cdr bounds))))
+ (delete-region (car bounds) (cdr bounds))
+ (goto-char (car bounds))
+ (insert (cj/--wrap text opening closing)))
+ (message "Can't wrap. No word at point and no region selected.")))))
+
+(defun cj/--unwrap (text opening closing)
+ "Internal implementation: Remove OPENING and CLOSING from TEXT if present.
+TEXT is the string to unwrap.
+OPENING is checked at the start of TEXT.
+CLOSING is checked at the end of TEXT.
+Returns the unwrapped text if both delimiters present, otherwise unchanged."
+ (if (and (string-prefix-p opening text)
+ (string-suffix-p closing text)
+ (>= (length text) (+ (length opening) (length closing))))
+ (substring text (length opening) (- (length text) (length closing)))
+ text))
+
+(defun cj/unwrap-word-or-region ()
+ "Remove surrounding delimiters from word at point or active region.
+The opening and closing strings are read from the minibuffer."
+ (interactive)
+ (let ((opening (read-string "Opening to remove: "))
+ (closing (read-string "Closing to remove: "))
+ (regionp (use-region-p)))
+ (if regionp
+ (let ((beg (region-beginning))
+ (end (region-end))
+ (text (buffer-substring (region-beginning) (region-end))))
+ (delete-region beg end)
+ (goto-char beg)
+ (insert (cj/--unwrap text opening closing)))
+ (if (thing-at-point 'word)
+ (let* ((bounds (bounds-of-thing-at-point 'word))
+ (text (buffer-substring (car bounds) (cdr bounds))))
+ (delete-region (car bounds) (cdr bounds))
+ (goto-char (car bounds))
+ (insert (cj/--unwrap text opening closing)))
+ (message "Can't unwrap. No word at point and no region selected.")))))
+
+(defun cj/--append-to-lines (text suffix)
+ "Internal implementation: Append SUFFIX to each line in TEXT.
+TEXT is the string containing one or more lines.
+SUFFIX is appended to the end of each line.
+Returns the transformed string without modifying the buffer."
+ (let* ((lines (split-string text "\n"))
+ (has-trailing-newline (string-suffix-p "\n" text))
+ ;; If has trailing newline, last element will be empty string - exclude it
+ (lines-to-process (if (and has-trailing-newline
+ (not (null lines))
+ (string-empty-p (car (last lines))))
+ (butlast lines)
+ lines)))
+ (concat
+ (mapconcat (lambda (line) (concat line suffix)) lines-to-process "\n")
+ (if has-trailing-newline "\n" ""))))
(defun cj/append-to-lines-in-region-or-buffer (str)
"Append STR to the end of each line in the region or entire buffer."
(interactive "sEnter string to append: ")
- (let ((start-pos (if (use-region-p)
- (region-beginning)
- (point-min)))
- (end-pos (if (use-region-p)
- (region-end)
- (point-max))))
- (save-excursion
- (goto-char start-pos)
- (while (< (point) end-pos)
- (move-end-of-line 1)
- (insert str)
- (forward-line 1)))))
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--append-to-lines text str)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+(defun cj/--prepend-to-lines (text prefix)
+ "Internal implementation: Prepend PREFIX to each line in TEXT.
+TEXT is the string containing one or more lines.
+PREFIX is prepended to the beginning of each line.
+Returns the transformed string without modifying the buffer."
+ (let* ((lines (split-string text "\n"))
+ (has-trailing-newline (string-suffix-p "\n" text))
+ ;; If has trailing newline, last element will be empty string - exclude it
+ (lines-to-process (if (and has-trailing-newline
+ (not (null lines))
+ (string-empty-p (car (last lines))))
+ (butlast lines)
+ lines)))
+ (concat
+ (mapconcat (lambda (line) (concat prefix line)) lines-to-process "\n")
+ (if has-trailing-newline "\n" ""))))
(defun cj/prepend-to-lines-in-region-or-buffer (str)
"Prepend STR to the beginning of each line in the region or entire buffer."
(interactive "sEnter string to prepend: ")
- (let ((start-pos (if (use-region-p)
- (region-beginning)
- (point-min)))
- (end-pos (if (use-region-p)
- (region-end)
- (point-max))))
- (save-excursion
- (goto-char start-pos)
- (while (< (point) end-pos)
- (beginning-of-line 1)
- (insert str)
- (forward-line 1)))))
-
-;; Surround, append, prepend prefix keymap
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--prepend-to-lines text str)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+(defun cj/--indent-lines (text count use-tabs)
+ "Internal implementation: Indent each line in TEXT by COUNT characters.
+TEXT is the string containing one or more lines.
+COUNT is the number of indentation characters to add.
+USE-TABS when non-nil uses tabs instead of spaces for indentation.
+Returns the indented text without modifying the buffer."
+ (let ((indent-string (if use-tabs
+ (make-string count ?\t)
+ (make-string count ?\s))))
+ (cj/--prepend-to-lines text indent-string)))
+
+(defun cj/indent-lines-in-region-or-buffer (count use-tabs)
+ "Indent each line in region or buffer by COUNT characters.
+COUNT is the number of characters to indent (default 4).
+USE-TABS when non-nil (prefix argument) uses tabs instead of spaces."
+ (interactive "p\nP")
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--indent-lines text count use-tabs)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+(defun cj/--dedent-lines (text count)
+ "Internal implementation: Remove up to COUNT leading characters from each line.
+TEXT is the string containing one or more lines.
+COUNT is the maximum number of leading whitespace characters to remove.
+Removes spaces and tabs, but only up to COUNT characters per line.
+Returns the dedented text without modifying the buffer."
+ (let* ((lines (split-string text "\n"))
+ (has-trailing-newline (string-suffix-p "\n" text))
+ (lines-to-process (if (and has-trailing-newline
+ (not (null lines))
+ (string-empty-p (car (last lines))))
+ (butlast lines)
+ lines))
+ (dedented-lines
+ (mapcar
+ (lambda (line)
+ (let ((removed 0)
+ (pos 0)
+ (len (length line)))
+ (while (and (< removed count)
+ (< pos len)
+ (memq (aref line pos) '(?\s ?\t)))
+ (setq removed (1+ removed))
+ (setq pos (1+ pos)))
+ (substring line pos)))
+ lines-to-process)))
+ (concat
+ (mapconcat #'identity dedented-lines "\n")
+ (if has-trailing-newline "\n" ""))))
+
+(defun cj/dedent-lines-in-region-or-buffer (count)
+ "Remove up to COUNT leading whitespace characters from each line.
+COUNT is the number of characters to remove (default 4).
+Works on region if active, otherwise entire buffer."
+ (interactive "p")
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--dedent-lines text count)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+;; Text enclosure keymap
(defvar-keymap cj/enclose-map
- :doc "Keymap for enclosing text: surrounding, appending, and prepending"
+ :doc "Keymap for text enclosure: wrapping, line manipulation, and indentation"
"s" #'cj/surround-word-or-region
+ "w" #'cj/wrap-word-or-region
+ "u" #'cj/unwrap-word-or-region
"a" #'cj/append-to-lines-in-region-or-buffer
- "p" #'cj/prepend-to-lines-in-region-or-buffer)
+ "p" #'cj/prepend-to-lines-in-region-or-buffer
+ "i" #'cj/indent-lines-in-region-or-buffer
+ "d" #'cj/dedent-lines-in-region-or-buffer)
(keymap-set cj/custom-keymap "s" cj/enclose-map)
(with-eval-after-load 'which-key