// lesson: operators

Operators โ€” the Verbs

Now the sentence. In vi, d alone does nothing โ€” it waits. The next motion tells it what to act on: dw deletes to the next word, d$ to the end of the line, dG to the end of the file, d2j this line and two below. One verb, every noun you know, and every noun you learn later multiplies through the grammar for free. This is the design idea that made vi immortal โ€” commands aren't memorized as monoliths; they're composed โ€” and the reason our motions were pure functions: d needs to ask "where would w go?" without moving anything.

Three verbs share all the machinery: d (delete), c (change: delete, then enter insert mode in the hole), y (yank: copy, change nothing). Plus the doubled forms โ€” dd, cc, yy โ€” meaning "this whole line": vi's convention for "the operator applied to itself" (a verb with no noun acts on lines).

Ranges: charwise vs linewise, exclusive vs inclusive

Resolving operator + motion produces a range, and vi's ranges come in two natures the range type must capture:

  • Charwise: from one character position to another. dw, d$, dh. The subtle bit is vi's exclusive/inclusive split, straight from the POSIX vi spec: motions that go to a place (w, b, 0, h, l) are exclusive โ€” the character at the destination survives; motions that go onto a thing (e, $ โ€” the end of a word, the last character) are inclusive โ€” the destination character dies too. dw from f in foo bar leaves bar (the b survives); de leaves bar (the o dies). We normalize both into one canonical form: start inclusive, end exclusive โ€” inclusive motions just add one to the end. Half-open ranges, same as STL iterators, same reasons: length is end - start, empty is start == end, no ยฑ1 guesswork downstream.
  • Linewise: whole lines, regardless of columns. dd, and any operator with an up/down motion (dj deletes two full lines โ€” yours and the one below; dG, dgg likewise whole-line spans). vi's rule: vertical motions make operators linewise. In the range this is a flag plus a row interval; columns are meaningless (we zero them).

Counts multiply through the grammar: 2dw and d2w and 2d3w are all legal; the counts multiply (2d3w = delete six words) โ€” the resolver takes the product and applies the motion that many times. For G/gg the count is a line number instead (grammar quirk, faithfully copied: d3G deletes from here through line 3).

An operator whose range turns out empty โ€” dh at column 0, d$ on an empty line โ€” does nothing at all: std::optional<Range> again, and the editor treats nullopt as "beep".

One vi special case is too load-bearing to skip: dw on the last word of a line. Plain grammar says "delete to the next word's start" โ€” on the next line! โ€” which would eat the line break. vi's actual rule (vim documents it as: an exclusive motion ending in column 1 retreats to the end of the previous line): when the w target lands on a later row, the range's end retreats to the end of the row just before the target. dw on the last word deletes to end of line; the newline survives. A second edge: w with the buffer running out mid-word (there is no next word start) โ€” then the operator extends to the end of the line, so dw on the file's final word still deletes it. Both are in the challenge spec and tests.

Applying the range

The second half is mechanical but detail-rich: given a resolved range, extract the doomed text (that's the yank โ€” vi always yanks what it deletes, which is why p after dd is "move line"), remove it for d/c, splice the seam for cross-line charwise ranges, and place the cursor per the verb: d clamps to normal mode's rule on the resulting line; c leaves the cursor at the hole, unclamped โ€” insert mode is about to begin, where sitting at len is legal; y doesn't edit at all, cursor to the range start (yank-moves-to-start is why yG seems to "jump": authentic). Linewise c (cc) has its own vi flavor: the lines are deleted but an empty line is left open at the spot, cursor on it โ€” change means "make me type the replacement".

Deleting every line linewise leaves {""} โ€” the buffer invariant from the buffer lesson, honored by every code path that can empty the file.

โ€บ Resolve Operator + Motion

20 pts

Implement resolve(lines, cur, op, motion, count) โ†’ std::optional<Range>:

  • motion == op (dd/cc/yy): linewise, rows cur.row through cur.row + reps - 1, clamped to the file.
  • j / k: linewise, cur.row through cur.row ยฑ reps, clamped, normalized so start.row <= end.row.
  • G: linewise, cur.row through line count (1-based; no count = last line), normalized. g (gg): same with no-count default line 1.
  • Charwise, exclusive (w, b, 0, h): apply the motion reps times (helpers provided); range between origin and destination, normalized to start < end, end exclusive. w fixups, in order: (a) target on a later row โ†’ retreat: end = {target.row - 1, len(target.row - 1)} (the column-1 retreat rule from the lesson); (b) target == cur, or target is not at a word start (the buffer ran out mid-word: its previous character exists, isn't blank, and has the same class) โ†’ extend: end = {cur.row, len(cur.row)}.
  • l computes its own end: {cur.row, min(cur.col + reps, len)} โ€” operator motions may stand one past the end of the line, which is exactly why dl deletes the character under the cursor.
  • Charwise, inclusive (e, $): destination character is included โ€” add one to the end column. $ takes the count-minus-one-rows-down rule from the motion engine; on an empty target line โ†’ nullopt. For e, a target that could not move still takes the character under the cursor (de at the very end of the buffer eats it).
  • Empty range โ†’ std::nullopt (charwise; a linewise range of one row is never empty). Linewise ranges: zero the cols.
  • count == 0 = no count; the caller already multiplied 2d3w into count = 6.

The starter provides working word_forward/word_back/word_end and apply_motion (your previous two challenges โ€” here given, so this challenge is purely about range algebra).

Log in to submit a solution and earn points.

โ€บ Apply the Operator

15 pts

Implement apply_op(lines, op, r) โ€” execute a resolved Range and return the EditResult:

  • yanked: the extracted text, one vector entry per range row โ€” linewise: full copies of each row; charwise same-row: the one segment; charwise multi-row: first row's tail, middle rows whole, last row's head (text before end.col).
  • 'y': buffer untouched; cursor = range start (for linewise: {start.row, 0}).
  • 'd' charwise: delete [start, end); a multi-row range splices first-head + last-tail into one line. Cursor: start, with the col clamped to the resulting line's normal-mode max.
  • 'd' linewise: remove the rows; {""} if that empties the buffer. Cursor: {min(start.row, new_last_row), 0}.
  • 'c' charwise: like d but the cursor col is not clamped (insert mode follows), and enter_insert is true.
  • 'c' linewise: the rows collapse to one empty line at start.row; cursor there, enter_insert true.
  • linewise in the result mirrors the range (your editor uses it to decide how p will paste later).

Ranges arrive well-formed (from resolve): non-empty, normalized, in-bounds.

Log in to submit a solution and earn points.