summaryrefslogtreecommitdiff
path: root/modules/custom-text-enclose.el
blob: e93e6deab638df3d9553c98ae76d4174d43b16c2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
;;; custom-text-enclose.el ---  -*- coding: utf-8; lexical-binding: t; -*-

;;; Commentary:

;; 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

;;; Code:

;; cj/custom-keymap defined in keybindings.el
(eval-when-compile (defvar cj/custom-keymap))

(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))

(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.
The surround string is read from the minibuffer."
  (interactive)
  (let ((str (read-string "Surround with: "))
        (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/--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)))
         (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)))
         (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 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
  "i" #'cj/indent-lines-in-region-or-buffer
  "d" #'cj/dedent-lines-in-region-or-buffer
  "I" #'change-inner
  "O" #'change-outer)

(keymap-set cj/custom-keymap "s" cj/enclose-map)
(with-eval-after-load 'which-key
  (which-key-add-key-based-replacements
    "C-; s" "text enclose menu"
    "C-; s s" "surround text"
    "C-; s w" "wrap text"
    "C-; s u" "unwrap text"
    "C-; s a" "append to lines"
    "C-; s p" "prepend to lines"
    "C-; s i" "indent lines"
    "C-; s d" "dedent lines"
    "C-; s I" "change inner"
    "C-; s O" "change outer"))

(provide 'custom-text-enclose)
;;; custom-text-enclose.el ends here.