aboutsummaryrefslogtreecommitdiff
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
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.
-rw-r--r--modules/custom-case.el43
-rw-r--r--tests/test-custom-case-title-case-region.el22
2 files changed, 47 insertions, 18 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)
diff --git a/tests/test-custom-case-title-case-region.el b/tests/test-custom-case-title-case-region.el
index 383ae927..82b4966a 100644
--- a/tests/test-custom-case-title-case-region.el
+++ b/tests/test-custom-case-title-case-region.el
@@ -60,16 +60,17 @@ an active region, and return the result."
"The Art of War")))
(ert-deftest test-custom-case-title-case-region-normal-all-minor-words ()
- "All minor words in the skip list should be lowercased in mid-sentence."
+ "All minor words in the skip list should be lowercased in mid-sentence.
+\"is\" is a linking verb, so it is a major word and is capitalized."
(should (equal (test-title-case--on-string
"go a an and as at but by for if in is nor of on or so the to yet go")
- "Go a an and as at but by for if in is nor of on or so the to yet Go")))
+ "Go a an and as at but by for if in Is nor of on or so the to yet Go")))
(ert-deftest test-custom-case-title-case-region-normal-four-letter-words-capitalized ()
"Words of four or more letters should always be capitalized.
-Note: 'is' is explicitly in the minor word list, so it stays lowercase."
+\"is\" is a linking verb (a major word), so it is capitalized too."
(should (equal (test-title-case--on-string "this is from that with over")
- "This is From That With Over")))
+ "This Is From That With Over")))
(ert-deftest test-custom-case-title-case-region-normal-allcaps-input ()
"All-caps input should be downcased first, then title-cased."
@@ -94,7 +95,16 @@ Note: 'is' is explicitly in the minor word list, so it stays lowercase."
(ert-deftest test-custom-case-title-case-region-normal-question-resets ()
"Word immediately after a question mark should be capitalized, even if minor."
(should (equal (test-title-case--on-string "really? the answer is no")
- "Really? The Answer is No")))
+ "Really? The Answer Is No")))
+
+(ert-deftest test-custom-case-title-case-region-normal-last-word-capitalized ()
+ "The last word is always capitalized, even a minor one."
+ (should (equal (test-title-case--on-string "the art of") "The Art Of")))
+
+(ert-deftest test-custom-case-title-case-region-normal-period-restarts ()
+ "A word after a sentence-ending period should be capitalized."
+ (should (equal (test-title-case--on-string "one. the next sentence")
+ "One. The Next Sentence")))
(ert-deftest test-custom-case-title-case-region-normal-hyphenated-word ()
"Second part of a hyphenated word should NOT be capitalized."
@@ -141,7 +151,7 @@ Note: 'is' is explicitly in the minor word list, so it stays lowercase."
(ert-deftest test-custom-case-title-case-region-boundary-unicode-words ()
"Unicode characters should pass through without error."
(should (equal (test-title-case--on-string "the café is nice")
- "The Café is Nice")))
+ "The Café Is Nice")))
(ert-deftest test-custom-case-title-case-region-boundary-numbers-in-text ()
"Numbers mixed with text should not break title casing."