Thursday, January 17, 2013

Soft-Semicolons: A little Emacs hack

I have been waging an all out war against the "Shift" key. I find that for programmers these keys, and in particular the left shift key, are used way too much. In my case, this overuse (paired with the common use of control key modifiers and the fact that my keyboard only has a control key on the left side) produced some numbness in my left pinky. This has since been eliminated via removing most of the "shifting" I do on a daily basis by using the Programmers Dvorak keyboard layout. In the process of doing this, however, I realized how annoying and disrupting it is to actually type a shift+key sequence in general. I realized that the annoyance of typing a colon before a symbol is one of the primary reasons that I tend to use the slightly problematic standard symbol notation in Loop or Iterate:

(loop for i below 10 by 2 collect i)

(iter (for i below 10 by 2)
  (collect i))

…rather than the more syntactically and stylistically pure keyword notation:

(loop :for i :below 10 :by 2 :collect i)

(iter (for i :below 10 :by 2)
  (collect i))

So, partly as an exercise in Emacs Lisp and partly just to scratch this personal itch, I decided to modify Emacs behavior in order to make colons very cheap to type. One option is to switch the semicolon and colon on your key map. This makes semicolons more expensive to type, with would be a pretty big loss for C coding where semicolons are much more common than colons. This might lead to the unfortunate situation where your key bindings are not the same between different modes (first layer colons in Lisp, first layer semicolons in C). This is a pretty messy solution.

What I really wanted was to have certain semicolons be converted to colons in certain situations. For instance, if I write a semicolon and then follow it with text (with no whitespace in between), it is very likely that I am trying to write a keyword, so I would like the preceding semicolon to be converted into a colon.

;keyword -> :keyword

But it is certainly the case that if I write a semicolon, then whitespace, then some text, I am trying to write a comment. In this case I want to leave the semicolon alone.

;; Some comment -> ;; Some comment

Naturally, since this is a little hack to save using the shift key, I would like these conversions to be transient, i.e. they only attempt to convert the semicolons if the very next character decides it. For instance, if I move the cursor to the front of some semicolons and start typing, those semicolons should be unaffected (the '_' marks the cursor):

_
;;

;;_

;;some text -> ;;some text

There were a few ways I could think about doing this, but the aim is to be unobtrusive. My solution was to rebind the semicolon key and have it read the characters and commands you give until you give one that isn't "type a semicolon", in which case it decides if it should convert the semicolons it typed on not. This is based very closely on the code in kmacro, namely the function kmacro-end-and-call-macro, which uses the same mechanism to temporarily bind a key (typically "e") to repeat the macro you just performed.

(defcustom *soft-semicolons-also-convert-on* '(9 tab)
  "This variable marks characters that will trigger semicolon
  conversion in addition to the non-whitespace printable
  character requirement.")

(defcustom *soft-semicolons-dont-convert-on* '(?( ?))
  "This variable allows you to exclude certain characters from
  triggering conversion.")

(defun soft-semicolons (arg)
  "Type semicolons like normal expect if they are immediately
followed by a non-whitespace character, in which case convert all
of the consequtive semicolons you were typing into colons."
  (interactive "p")
  (let ((keep-going t)
        (start-point (point)))
    (insert 59)
    (while keep-going
       (let ((event (read-event)))

         (cond ((equal 59 event)
                ;; A Semicolon, insert and keep going
                (clear-this-command-keys t)
                (insert 59)
                (setq last-input-event nil))

               ((member event *soft-semicolons-dont-convert-on*)
                ;; For these special cases, don't do any conversion
                (setq keep-going nil))

               ;; A non-whitespace printable character or something in
               ;; *soft-semiclons-also-convert-on*
               ((or (member event *soft-semicolons-also-convert-on*)
                    (and (integerp event)
                         ;; See if this is a printable character
                         (aref printable-chars event)
                         ;; Rule out whitespace characters (which might also be
                         ;; printable)
                         (not (member event '(9 10 13 32)))))
                (let ((length (- (point) start-point)))
                  (delete-region start-point (point))
                  (insert-char 58 length))
                ;; ...and exit...
                (setq keep-going nil))

               ;; Exit on anything else
               (t (setq keep-going nil)))))

    ;; Push any residual command back onto unread-command-events to be read and
    ;; processed
    (when last-input-event
      (clear-this-command-keys t)
      (setq unread-command-events (list last-input-event)))))

Ironically enough, Common Lisp is one of the only languages I can think of where this soft-semicolon thing interferes with standard syntax. Logical pathname namestrings use semicolons as the delimiter between directories. These directories are necessarily whitespace dependent, and this means that this little hack will make it very annoying to insert logical pathname namestrings. I have never used a logical pathname, I'm pretty sure I never intend to, so I guess this isn't a huge concern for me.

I threw the code up on Github in case you'd like it. This is one of my first forays into Emacs Lisp coding, so I am even more grateful than usual for any comments on how this is implemented or how it could be implemented in a better way.

No comments:

Post a Comment