Add titlecase

This commit is contained in:
Case Duckworth 2021-09-01 08:37:17 -05:00
parent 5fb6e89bfc
commit 3d10340f69
2 changed files with 166 additions and 3 deletions

12
init.el
View File

@ -976,11 +976,16 @@ like a dumbass."
(setq read-extended-command-predicate
#'command-completion-default-include-p))
(defvar case-map (make-sparse-keymap)
"A keymap for setting case in various ways.")
(global-set-key (kbd "C-c c") case-map)
(:global "M-=" count-words
"C-w" kill-region-or-backward-word
"C-c c" capitalize-dwim
"C-c u" upcase-dwim
"C-c l" downcase-dwim
"C-c c c" capitalize-dwim
"C-c c t" titlecase-dwim
"C-c c u" upcase-dwim
"C-c c l" downcase-dwim
"C-c d" acdw/insert-iso-date
"M-`" nil)
@ -999,6 +1004,7 @@ like a dumbass."
(defalias 'backward-word-with-case 'backward-word
"Alias for `backward-word for use in `case-repeat-map'.")
;; XXX: this isn't repeating correctly ...
(defvar case-repeat-map
(let ((map (make-sparse-keymap)))
(define-key map "c" #'capitalize-word)

157
lisp/titlecase.el Normal file
View File

@ -0,0 +1,157 @@
;;; titlecase.el -*- lexical-binding: t; -*-
;; https://hungyi.net/posts/programmers-way-to-title-case/
(require 'cl-lib)
(require 'subr-x)
;;;###autoload
(defun titlecase-string (str)
"Convert string STR to title case and return the resulting string."
(let* ((case-fold-search nil)
(str-length (length str))
;; A list of markers that indicate start of a new phrase within the
;; title, e.g. "The Lonely Reindeer: A Christmas Story"
;; must be followed by one of word-boundary-chars
(new-phrase-chars '(?: ?. ?? ?\; ?\n ?\r))
;; immediately triggers new phrase behavior without waiting for word
;; boundary
(immediate-new-phrase-chars '(?\n ?\r))
;; A list of characters that indicate "word boundaries"; used to split
;; the title into processable segments
(word-boundary-chars (append '(? ? ?— ?- ? ?/)
immediate-new-phrase-chars))
;; A list of small words that should not be capitalized (in the right
;; conditions)
(small-words '("a" "an" "and" "as" "at" "but" "by" "en" "for" "if"
"in" "of" "on" "or" "the" "to" "v" "v." "vs" "vs."
"via"))
;; Fix if str is ALL CAPS
(str (if (string-match-p "[a-z]" str) str (downcase str)))
;; Reduce over a state machine to do title casing
(final-state
(cl-reduce
(lambda (state char)
(let* ((result (aref state 0))
(last-segment (aref state 1))
(first-word-p (aref state 2))
(was-in-path-p (aref state 3))
(last-char (car last-segment))
(in-path-p (or (and (eq char ?/)
(or (not last-segment)
(member last-char '(?. ?~))))
(and was-in-path-p
(not
(or (eq char ? )
(member
char
immediate-new-phrase-chars))))))
(end-p
;; are we at the end of the input string?
(eq (+ (length result) (length last-segment) 1)
str-length))
(pop-p
;; do we need to pop a segment onto the output result?
(or end-p (and (not in-path-p)
(member char word-boundary-chars))))
(segment
;; add the current char to the current segment
(cons char last-segment))
(segment-string
;; the readable version of the segment
(apply #'string (reverse segment)))
(small-word-p
;; was the last segment a small word?
(member (downcase (substring segment-string 0 -1))
small-words))
(capitalize-p
;; do we need to capitalized this segment or lowercase it?
(or end-p first-word-p (not small-word-p)))
(ignore-segment-p
;; ignore explicitly capitalized segments
(or (string-match-p "[a-zA-Z].*[A-Z]" segment-string)
;; ignore URLs
(string-match-p "^https?:" segment-string)
;; ignore hostnames and namespaces.like.this
(string-match-p "\\w\\.\\w" segment-string)
;; ignore windows filesystem paths
(string-match-p "^[A-Za-z]:\\\\" segment-string)
;; ignore unix filesystem paths
was-in-path-p
;; ignore email addresses and user handles with @ symbol
(member ?@ segment)))
(next-result
(if pop-p
(concat result
(if ignore-segment-p
;; pop segment onto the result without
;; processing
segment-string
;; titlecase the segment before popping onto
;; result
(titlecase--segment
segment-string capitalize-p)))
result))
(next-segment
(unless pop-p segment))
(will-be-first-word-p
(if pop-p
(or (not last-segment)
(member last-char new-phrase-chars)
(member char immediate-new-phrase-chars))
first-word-p)))
(vector
next-result next-segment will-be-first-word-p in-path-p)))
str
:initial-value
(vector nil ; result stack
nil ; current working segment
t ; is it the first word of a phrase?
nil)))) ; are we inside of a filesystem path?
(aref final-state 0)))
(defun titlecase--segment (segment capitalize-p)
"Convert a title's inner SEGMENT to capitalized or lower case
depending on CAPITALIZE-P, then return the result."
(let* ((case-fold-search nil)
(ignore-chars '(?' ?\" ?\( ?\[ ? ?“ ? ?” ?_))
(final-state
(cl-reduce
(lambda (state char)
(let ((result (aref state 0))
(downcase-p (aref state 1)))
(cond
(downcase-p
;; already upcased start of segment, so lowercase the rest
(vector (cons (downcase char) result) t))
((member char ignore-chars)
;; check if start char of segment needs to be ignored
(vector (cons char result) downcase-p))
(t
;; haven't upcased yet, and we can, so do it
(vector (cons (upcase char) result) t)))))
segment
:initial-value (vector nil (not capitalize-p)))))
(thread-last (aref final-state 0)
(reverse)
(apply #'string))))
;;;###autoload
(defun titlecase-region (begin end)
"Convert text in region from BEGIN to END to title case."
(interactive "*r")
(let ((pt (point)))
(insert (titlecase-string (delete-and-extract-region begin end)))
(goto-char pt)))
;;;###autoload
(defun titlecase-dwim ()
"Convert the region or current line to title case.
If Transient Mark Mode is on and there is an active region, convert
the region to title case. Otherwise, work on the current line."
(interactive)
(if (and transient-mark-mode mark-active)
(titlecase-region (region-beginning) (region-end))
(titlecase-region (point-at-bol) (point-at-eol))))
(provide 'titlecase)