// lesson: motions

Motions โ€” the Nouns

In vi's grammar, a motion is a noun phrase: a place in the text, named relative to the cursor. w โ€” the next word. $ โ€” the end of the line. G โ€” the last line. Pressed alone, a motion moves the cursor there. The deeper payoff comes next lesson, when the same nouns become the objects of verbs (d w: delete to the next word) โ€” which is exactly why motions must be pure functions (buffer, position, count) โ†’ position, with no side effects: the operator machinery will call them to measure text without moving anything.

One invariant governs everything: in normal mode the cursor sits on a character, never past the last one. Valid columns are 0 .. max(0, len-1) (an empty line parks the cursor at column 0 โ€” the one place it sits on nothing). Insert mode relaxes this โ€” the cursor may sit at len, "after everything" โ€” which is why a (append) at the end of a line can insert where normal mode won't stand. Every motion ends by re-imposing the clamp.

The core motions, and the details that make them feel right:

  • h l โ€” left/right, clamped to the line. No line wrapping: authentic vi l at line end just stops. (vim's whichwrap option exists precisely because people argue about this.)
  • j k โ€” down/up, and here's the classic subtlety: moving from a long line to a short one must clamp the column to the short line's end. vim actually remembers the "goal column" so j-j through a short line pops back out to the original column; we make the honest simplification of clamping without memory (the final challenge keeps it too, and upgrading is a great post-course exercise).
  • 0 โ€” column zero, unconditionally. ^ โ€” the first non-blank (indentation-aware "start of line"; on an all-blank line there's nothing non-blank, so it clamps to the last column). The difference matters in real code: 0 on " return x;" reaches column 0, ^ reaches the r.
  • $ โ€” the last character. With a count, 2$ means "end of the next line" (count minus one rows down, then end) โ€” a real vi refinement the tests cover.
  • G โ€” with no count, the last line; with a count, that line (1-based: 42G = line 42, clamped to the file). gg โ€” the same with the default flipped: no count means line one. (Real vi moves to the first non-blank after G; we keep the column, clamped โ€” modern-editor behavior, and one less moving part.)
  • Counts: 5j is j five times; 0, ^, $ treat counts their own way (0 and ^ ignore them). A count of 0 in our API means "no count given" โ€” vi cannot even express a count of zero, since 0 is itself a motion; our implementation honors that piece of grammar trivia directly in the representation.

Word motions: the fiddliest fifty lines in vi

w, b, e โ€” forward word, back word, end of word โ€” are where "word" needs a definition, and vi's is precise. Characters divide into three classes: word characters (letters, digits, underscore โ€” C identifier characters, not a coincidence), punctuation (every other non-blank), and whitespace. A word is a maximal run of a single non-blank class. In foo_bar+=42:

foo_bar   +=   42        three words: word-class, punct-class, word-class

So w from f lands on +; w again lands on 4. Punctuation runs are words of their own class โ€” that's what makes w useful in code, hopping ->, ::, += as single units. (vi also has W/B/E โ€” WORD motions, whitespace-delimited only, one class instead of two โ€” an easy extension once small-word logic works.)

The rules that complete the spec:

  • w: skip the rest of the current word (if on one), then skip blanks, land on the first character of the next word. e: move at least one character, skip blanks, land on the last character of the current/next word. b: mirror of e, backward: land on the first character of the current/previous word.
  • Lines end, words end. A word never spans a line break: the break acts as whitespace between the last word of one line and the first of the next. All three motions cross lines freely.
  • Empty lines are words โ€” for w and b, which stop on them; e skips them entirely. (Try it in vim: w through a paragraph break pauses on the blank line, e sails past.) This asymmetry is authentic, ancient, and pinned by the tests.
  • At the edges: a motion that cannot move (e.g. w at the last word's last character... actually anywhere in the file's final stretch) returns its input position โ€” the caller sees "no movement" rather than an error.

Implementation advice: model the line break as a virtual whitespace character sitting one column past each line's last character (except the final line). The starter's next_pos/prev_pos helpers implement exactly that iteration; with them, each motion is a short loop over character classes, and all the line-crossing edge cases dissolve into "the slot is whitespace". Getting these three functions right is the single most re-used work in the course: the operator lesson and the final challenge both build directly on them.

โ€บ The Motion Engine

15 pts

Implement apply_motion(lines, p, motion, count) for the line motions: h l j k 0 ^ $ G g (where 'g' denotes gg), with the semantics and clamping described above. count == 0 means no count given. The returned position always satisfies the normal-mode clamp; the input position is always valid.

Log in to submit a solution and earn points.

โ€บ Word Motions

20 pts

Implement word_forward (w), word_back (b), and word_end (e) per the class rules above โ€” one application each (counts are the caller's loop). The starter provides the character classifier and the virtual-newline position iterators; your job is the three class-run loops. Inputs are valid normal-mode positions; outputs must be too.

Log in to submit a solution and earn points.