TheClapp/ blog/ posts/ DSL: cursor movement in Vim-mode

I haven't had a lot of time for blogging lately, what with work and the chorus I'm in and trying to absorb the Getting Things Done methodology that I picked up over New Years. So I thought I'd try to jump back in and talk about a small DSL I wrote for moving the cursor around in Vim-mode.

Vim has many commands for moving the cursor around by what it calls "words" and "WORDS", which I call :words and :bigwords.

According to the Vim help file:

A word consists of a sequence of letters, digits and underscores, or a sequence of other non-blank characters, separated with white space (spaces, tabs, <EOL>). This can be changed with the 'iskeyword' option. An empty line is also considered to be a word.

A WORD consists of a sequence of non-blank characters, separated with white space. An empty line is also considered to be a WORD.

The basic core of this kind of movement is, search (forward or backwards) until you find a character that is (or isn't) whitespace or a keyword.

My approach to coding is to get it to work and then make it pretty, so I wrote functions to a) figure out if the current character is in a certain class (e.g. :whitespace or (:not :keyword :whitespace)), b) search (forward or backwards) for characters in a given list of classes, and c) write direct calls to these to move the cursor around.

The first one looks like this:

(defmethod vim-char-attribute ((type symbol) &optional (point (current-point)))
  (member (next-character point) (gethash type b-vim-char-attributes)))
(defmethod vim-char-attribute ((types list) &optional (point (current-point)))
  (let* ((invert-p (eql (car types) :not))
         (types (if invert-p (cdr types) types)))
    (loop for type in types
          if (vim-char-attribute type point)
          return (not invert-p)
          finally return invert-p)))

the second one looks like this:

(defun vim-find-attribute (forward attributes &optional (point (current-point)))
  (with-point ((started-at point))
    (let ((offset (if forward 1 -1)))
      (loop while (and (not (vim-char-attribute attributes point))
                       (character-offset point offset)))
      (and (point/= started-at point)
           (vim-char-attribute attributes point)))))

and I long since deleted the third one and rewrote it, so I don't have an example of that one. Suffice to say, it was pretty unreadable. In retrospect, it reminds me of what Kent Pitman (I think) has said about printed output in Lisp, before the advent of format -- the stuff you actually wanted to print (or the cursor movements I wanted to make) got lost in the rest of the code.

So once you get that out of the way, you find that the next layer is basically finding boundaries, which means skipping the current kind of character and then leaving the cursor in the right place. You skip the current kind of character by figuring out what kind of character you're currently on (i.e. :whitespace, :keyword, or (:not :whitespace :keyword)), and then moving until you run out of that kind of character.

Vim has several movement commands, both forwards and backwards, that leave the cursor at the beginning or end of the :word (or :bigword).

w                       [count] words forward.

W                       [count] WORDS forward.

e                       Forward to the end of word [count]

E                       Forward to the end of WORD [count]

b                       [count] words backward.

B                       [count] WORDS backward.

ge                      Backward to the end of word [count]

gE                      Backward to the end of WORD [count]

One really interesting part of the process came when I realized that the algorithm for w and ge are the same, just going different directions, and similarly for e and b. So if you're going forward to the front of the next word (w), or you're going backwards to the end of the previous word (ge), you're doing the same thing: find a boundary, and then skip whitespace.

So I wrote a couple of methods to move the cursor by one or more :words, or one or more :bigwords. Here's the former, along with the defgeneric:

(defgeneric vim-offset (n type forward point &key &allow-other-keys))
(defmethod vim-offset (count (type (eql :word)) forward point &key end (word-type :keyword))
  (setf count (or count 1))
  (loop for n below count
        while
        ; This code highlights that e & b are inverses of each other, and
        ; w and ge are inverses of each other.  That is, e & b do the same
        ; things in opposite directions; same for w and ge.
        (with-move (forward point :word-type word-type)
          (cond ((xor forward end)
                 (boundary)
                 (skip :whitespace))
                (t (bump)
                   (skip :whitespace)
                   (boundary :end))))
        finally return (= n count)))

And now we get to the meat of it: with-move. with-move defines a context where you can easily move forwards or backwards, turn around, bump the cursor one character one way or the other, and so on, and not have to keep repeating the movement direction or variable name with the cursor in it.

The core subfunctions are

There are also some auxiliary subfunctions, among them

And one final feature: if any subfunction fails (i.e. you've come to the beginning or end of the buffer), quit immediately. This is acheived by wrapping most movement in a local must macro, that says basically "do all the movement in order; if any fail, exit with-move immediately".

(macrolet ((must (&rest x)
              `(let ((res (and ,@x)))
                 (if res res (return-from with-move nil)))))
  [...])

And finally, here's the macro itself:

(defmacro with-move ((forward point &key (word-type :keyword)) &body body)
  (rebinding (forward point)
    `(block with-move
       (macrolet ((must (&rest x)
                    `(let ((res (and ,@x)))
                       (if res res (return-from with-move nil)))))
         (labels ((set-point (new-point)
                    (setf ,point new-point))
                  (set-direction (new-direction)
                    (setf ,forward new-direction))
                  (go-forward ()
                    (setf ,forward t))
                  (u-turn ()
                    (setf ,forward (not ,forward)))
                  (invert (list)
                    (if (eql (car list) :not)
                      (cdr list)
                      (cons :not list)))
                  (skip-current ()
                    (loop for attrib in (list '(:whitespace)
                                              (list ,word-type)
                                              (list :not :whitespace ,word-type))
                          until
                          (vim-char-attribute attrib ,point)
                          finally return
                          (must (vim-find-attribute ,forward (invert attrib) ,point))))
                  (fix-endp (endp)
                    (if endp
                      (must (character-offset ,point (if ,forward -1 1)))
                      t))
                  (boundary (&optional endp)
                    (must (skip-current)
                          (fix-endp endp)))
                  (skip (&rest attribute)
                    (let ((inverted-attribute (invert attribute)))
                      (or (vim-char-attribute inverted-attribute ,point)
                          (must (vim-find-attribute ,forward inverted-attribute ,point)))))
                  (bump ()
                    (must (character-offset ,point (if ,forward 1 -1))))
                  (unbump ()
                    (must (character-offset ,point (if ,forward -1 1))))
                  (find (attribute &optional endp)
                    (skip (invert attribute))
                    (fix-endp endp)))
           ,@body)))))

I'm not completely happy with this macro. It defines 12 new functions every time you use it. (Lispworks may be smart enough to compile-away any you don't use in any given invocation, but it still bugs me.) I'm thinking about changing most of those local subfunctions to be global functions that take defaulted optional arguments, and changing with-move to just establish a context that sets the defaults once so you can ignore them. I've run out of time just now or I'd try to give an example of what I mean, but that'll have to wait for later.

In the meantime, happy Lisping!