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
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.
(: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
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
(: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
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 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 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
skip-current: figure out what you're on and skip to the end of it
boundary: skip-current, and then leave the cursor in the right place
skip: given a character type, move until you're not on that type any more. If you're already on that type, don't move at all
There are also some auxiliary subfunctions, among them
u-turn: change the direction you're moving
bump: move the cursor one character in the current direction
unbump: move the cursor one character in the opposite of the current direction
find: given an attribute, find the first character of that kind, leaving the cursor in the right place
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
must macro, that says basically "do all the movement in order; if any fail, exit
(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!