summaryrefslogtreecommitdiff
path: root/modules/custom-whitespace.el
blob: df93459a5da6185d3daee5f5f25e648a123d9738 (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
;;; custom-whitespace.el ---  -*- coding: utf-8; lexical-binding: t; -*-

;;; Commentary:

;; This module provides whitespace manipulation operations for cleaning and transforming whitespace in text.

;; Functions include:

;; - removing leading and trailing whitespace
;; - collapsing multiple spaces to single spaces
;; - deleting blank lines
;; - converting whitespace to hyphens.

;; All operations work on the current line, active region, or entire buffer depending on context.

;; Bound to keymap prefix C-; w

;;; Code:

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

;;; ---------------------- Whitespace Operations And Keymap ---------------------

;; ------------------- Remove Leading/Trailing Whitespace ---------------------

(defun cj/--remove-leading-trailing-whitespace (start end)
  "Internal implementation: Remove leading and trailing whitespace.
START and END define the region to operate on.
Removes leading whitespace (^[ \\t]+) and trailing whitespace ([ \\t]+$).
Preserves interior whitespace."
  (when (> start end)
    (error "Invalid region: start (%d) is greater than end (%d)" start end))
  (save-excursion
    (save-restriction
      (narrow-to-region start end)
      (goto-char (point-min))
      (while (re-search-forward "^[ \t]+" nil t) (replace-match ""))
      (goto-char (point-min))
      (while (re-search-forward "[ \t]+$" nil t) (replace-match "")))))

(defun cj/remove-leading-trailing-whitespace ()
  "Remove leading and trailing whitespace in a region, line, or buffer.
When called interactively:
- If a region is active, operate on the region.
- If called with a \\[universal-argument] prefix, operate on the entire buffer.
- Otherwise, operate on the current line."
  (interactive)
  (let ((start (cond (current-prefix-arg (point-min))
					 ((use-region-p) (region-beginning))
					 (t (line-beginning-position))))
		(end (cond (current-prefix-arg (point-max))
				   ((use-region-p) (region-end))
				   (t (line-end-position)))))
	(cj/--remove-leading-trailing-whitespace start end)))

;; ----------------------- Collapse Whitespace ---------------------------------

(defun cj/--collapse-whitespace (start end)
  "Internal implementation: Collapse whitespace to single spaces.
START and END define the region to operate on.
Converts tabs to spaces, removes leading/trailing whitespace,
and collapses multiple spaces to single space.
Preserves newlines and line structure."
  (when (> start end)
    (error "Invalid region: start (%d) is greater than end (%d)" start end))
  (save-excursion
    (save-restriction
      (narrow-to-region start end)
      ;; Replace all tabs with space
      (goto-char (point-min))
      (while (search-forward "\t" nil t)
        (replace-match " " nil t))
      ;; Remove leading and trailing spaces (but not newlines)
      (goto-char (point-min))
      (while (re-search-forward "^[ \t]+\\|[ \t]+$" nil t)
        (replace-match "" nil nil))
      ;; Ensure only one space between words (but preserve newlines)
      (goto-char (point-min))
      (while (re-search-forward "[ \t]\\{2,\\}" nil t)
        (replace-match " " nil nil)))))

(defun cj/collapse-whitespace-line-or-region ()
  "Collapse whitespace to one space in the current line or active region.
Ensure there is exactly one space between words and remove leading and
trailing whitespace."
  (interactive)
  (let* ((region-active (use-region-p))
         (beg (if region-active (region-beginning) (line-beginning-position)))
         (end (if region-active (region-end) (line-end-position))))
    (cj/--collapse-whitespace beg end)))

;; --------------------- Ensure Single Blank Line ------------------------------

(defun cj/--ensure-single-blank-line (start end)
  "Internal implementation: Collapse consecutive blank lines to one.
START and END define the region to operate on.
Replaces runs of 2+ blank lines with exactly one blank line.
A blank line is defined as a line containing only whitespace."
  (when (> start end)
    (error "Invalid region: start (%d) is greater than end (%d)" start end))
  (save-excursion
    (save-restriction
      (narrow-to-region start end)
      (goto-char (point-min))
      ;; Match 2+ consecutive blank lines (lines with only whitespace)
      ;; Pattern: Match sequences of blank lines (newline + optional space + newline)
      ;; but preserve leading whitespace on the following content line
      ;; Match: newline, then 1+ (optional whitespace + newline), capturing the last one
      (while (re-search-forward "\n\\(?:[[:space:]]*\n\\)+" nil t)
        (replace-match "\n\n")))))

(defun cj/ensure-single-blank-line (start end)
  "Collapse consecutive blank lines to exactly one blank line.
START and END define the region to operate on.
Operates on the active region when one exists.
Prompt before operating on the whole buffer when no region is selected."
  (interactive
   (if (use-region-p)
       (list (region-beginning) (region-end))
     (if (yes-or-no-p "Ensure single blank lines in entire buffer? ")
         (list (point-min) (point-max))
       (user-error "Aborted"))))
  (cj/--ensure-single-blank-line start end))

;; ------------------------ Delete Blank Lines ---------------------------------

(defun cj/--delete-blank-lines (start end)
  "Internal implementation: Delete blank lines between START and END.
Blank lines are lines containing only whitespace or nothing.
Uses the regexp ^[[:space:]]*$ to match blank lines."
  (when (> start end)
    (error "Invalid region: start (%d) is greater than end (%d)" start end))
  (save-excursion
    (save-restriction
      (widen)
      ;; Regexp "^[[:space:]]*$" matches lines of zero or more spaces/tabs/newlines.
      (flush-lines "^[[:space:]]*$" start end))))

(defun cj/delete-blank-lines-region-or-buffer (start end)
  "Delete blank lines between START and END.
Treat blank lines as lines that contain nothing or only whitespace.
Operate on the active region when one exists.
Prompt before operating on the whole buffer when no region is selected.
Signal a user error and do nothing when the user declines.
Restore point to its original position after deletion."
  (interactive
   (if (use-region-p)
       ;; grab its boundaries if there's a region
       (list (region-beginning) (region-end))
     ;; or ask if user intended operating on whole buffer
     (if (yes-or-no-p "Delete blank lines in entire buffer? ")
         (list (point-min) (point-max))
       (user-error "Aborted"))))
  (cj/--delete-blank-lines start end)
  ;; Return nil (Emacs conventions). Point is already restored.
  nil)

;; ------------------------- Delete All Whitespace -----------------------------

(defun cj/--delete-all-whitespace (start end)
  "Internal implementation: Delete all whitespace from region.
START and END define the region to operate on.
Removes all spaces, tabs, newlines, and carriage returns."
  (when (> start end)
    (error "Invalid region: start (%d) is greater than end (%d)" start end))
  (save-excursion
    (save-restriction
      (narrow-to-region start end)
      (goto-char (point-min))
      (while (re-search-forward "[ \t\n\r]+" nil t)
        (replace-match "")))))

(defun cj/delete-all-whitespace (start end)
  "Delete all whitespace between START and END.
Removes all spaces, tabs, newlines, and carriage returns.
Operates on the active region."
  (interactive "*r")
  (if (use-region-p)
      (cj/--delete-all-whitespace start end)
    (message "No region; nothing to delete.")))

;; ------------------------- Hyphenate Whitespace ------------------------------

(defun cj/--hyphenate-whitespace (start end)
  "Internal implementation: Replace whitespace runs with hyphens.
START and END define the region to operate on.
Replaces all runs of spaces, tabs, newlines, and carriage returns with hyphens."
  (when (> start end)
    (error "Invalid region: start (%d) is greater than end (%d)" start end))
  (save-excursion
    (save-restriction
      (narrow-to-region start end)
      (goto-char (point-min))
      (while (re-search-forward "[ \t\n\r]+" nil t)
        (replace-match "-")))))

(defun cj/hyphenate-whitespace-in-region (start end)
  "Replace runs of whitespace between START and END with hyphens.
Operate on the active region designated by START and END."
  (interactive "*r")
  (if (use-region-p)
      (cj/--hyphenate-whitespace start end)
    (message "No region; nothing to hyphenate.")))


;; Whitespace operations prefix and keymap
(defvar-keymap cj/whitespace-map
  :doc "Keymap for whitespace operations"
  "r" #'cj/remove-leading-trailing-whitespace
  "c" #'cj/collapse-whitespace-line-or-region
  "l" #'cj/delete-blank-lines-region-or-buffer
  "1" #'cj/ensure-single-blank-line
  "d" #'cj/delete-all-whitespace
  "-" #'cj/hyphenate-whitespace-in-region
  "t" #'untabify
  "T" #'tabify)

(keymap-set cj/custom-keymap "w" cj/whitespace-map)
(with-eval-after-load 'which-key
  (which-key-add-key-based-replacements "C-; w" "whitespace menu"))

(provide 'custom-whitespace)
;;; custom-whitespace.el ends here.