aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-11 14:58:30 -0500
committerCraig Jennings <c@cjennings.net>2026-07-11 14:58:30 -0500
commite52668985817b92f296606707694729b866e8364 (patch)
tree6b4214ff67acee159ac4e78a3a8ebd2f32dcf55a /modules
parent734172047b05a7dd4d75c86f8aae66c5ae807de0 (diff)
downloaddotemacs-e52668985817b92f296606707694729b866e8364.tar.gz
dotemacs-e52668985817b92f296606707694729b866e8364.zip
fix(custom-case): align title-case with its docstring and standard rules
cj/title-case-region contradicted its own docstring in three ways. It lowercased "is" despite the docstring calling linking verbs major words; it never restarted capitalization after a sentence-ending period; and it had no rule to capitalize the last word. I made the code match the standard: dropped "is" from the minor-word skip list, added the period to the reset characters (a period plus a blank restarts, so "3.14" and "foo.bar" are still left alone), and always capitalize the last word by locating its start once. Updated the four characterization tests that asserted the old behavior and added tests for the last-word and period-restart rules.
Diffstat (limited to 'modules')
-rw-r--r--modules/custom-case.el43
1 files changed, 31 insertions, 12 deletions
diff --git a/modules/custom-case.el b/modules/custom-case.el
index 87622695..dde2d6c1 100644
--- a/modules/custom-case.el
+++ b/modules/custom-case.el
@@ -49,13 +49,14 @@
(downcase-region (car bounds) (cdr bounds))
(user-error "No symbol at point")))))
-(defun cj/--title-case-capitalize-word-p (word is-first prev-word-end word-skip chars-skip-reset)
+(defun cj/--title-case-capitalize-word-p (word is-first is-last prev-word-end word-skip chars-skip-reset)
"Return non-nil when WORD at point should be capitalized in title case.
Point is at WORD's first character. WORD is capitalized when it is the first
-word (IS-FIRST), is not a minor skip word (in WORD-SKIP), or immediately follows
-a skip-reset character (one of CHARS-SKIP-RESET: : ! ?), reached by skipping
-blanks back to PREV-WORD-END."
+word (IS-FIRST) or the last word (IS-LAST), is not a minor skip word (in
+WORD-SKIP), or immediately follows a skip-reset character (one of
+CHARS-SKIP-RESET: : ! ? .), reached by skipping blanks back to PREV-WORD-END."
(or is-first
+ is-last
(not (member word word-skip))
(save-excursion
(and (not (zerop (skip-chars-backward "[:blank:]" prev-word-end)))
@@ -67,22 +68,30 @@ Title case is a capitalization convention where major words are capitalized,
and most minor words are lowercase. Nouns, verbs (including linking verbs),
adjectives, adverbs,pronouns, and all words of four letters or more are
considered major words. Short (i.e., three letters or fewer) conjunctions,
-short prepositions, and all articles are considered minor words."
+short prepositions, and all articles are considered minor words. The first
+and last words are always capitalized, and a word following a sentence-ending
+period (or a colon, exclamation mark, or question mark) restarts
+capitalization even when it is a minor word."
(interactive)
(let ((beg nil)
(end nil)
(prev-word-end nil)
- ;; Allow capitals for skip characters after this, so:
- ;; Warning: An Example
- ;; Capitalizes the `An'.
- (chars-skip-reset '(?: ?! ??))
+ (last-word-start nil)
+ ;; Restart capitalization for a minor word after one of these, so
+ ;; "Warning: An Example" capitalizes the "An" and a sentence-ending
+ ;; period capitalizes the next word ("End. The Next").
+ (chars-skip-reset '(?: ?! ?? ?.))
;; Don't capitalize characters directly after these. e.g.
- ;; "Foo-bar" or "Foo\bar" or "Foo's".
+ ;; "Foo-bar" or "Foo\bar" or "Foo's". A period is here too, so
+ ;; "3.14" and "foo.bar" are left alone; a period followed by a
+ ;; blank still restarts via `chars-skip-reset' above.
(chars-separator '(?\\ ?- ?' ?.))
(word-chars "[:alnum:]")
+ ;; "is" and other linking verbs are major words, so they are not in
+ ;; this minor-word skip list.
(word-skip
(list "a" "an" "and" "as" "at" "but" "by"
- "for" "if" "in" "is" "nor" "of"
+ "for" "if" "in" "nor" "of"
"on" "or" "so" "the" "to" "yet"))
(is-first t))
(cond
@@ -92,6 +101,15 @@ short prepositions, and all articles are considered minor words."
(t
(setq beg (line-beginning-position))
(setq end (line-end-position))))
+ ;; The last word is always capitalized in title case, so find its start
+ ;; once: from END, skip back over any trailing non-word chars, then over
+ ;; the word itself.
+ (setq last-word-start
+ (save-excursion
+ (goto-char end)
+ (skip-chars-backward (concat "^" word-chars) beg)
+ (skip-chars-backward word-chars beg)
+ (point)))
(save-excursion
;; work on uppercased text (e.g., headlines) by downcasing first
(downcase-region beg end)
@@ -112,7 +130,8 @@ short prepositions, and all articles are considered minor words."
(unless (string-equal c-orig c-up)
(let ((word (buffer-substring-no-properties (point) word-end)))
(when (cj/--title-case-capitalize-word-p
- word is-first prev-word-end word-skip chars-skip-reset)
+ word is-first (= (point) last-word-start)
+ prev-word-end word-skip chars-skip-reset)
(delete-region (point) (1+ (point)))
(insert c-up))))))
(goto-char word-end)