ADR 0099: Four vim rules the editor was missing¶
Status¶
Accepted
Context¶
Two behaviours had been known-wrong and documented for a while: 2D
left a blank line vim does not, and vGd deleted more than vim. Fixing
them turned up two more, and the four together are worth recording
because each is a rule vim has that is not obvious from the outside —
the kind of thing found by running vim, not by reading about it.
Every case below was verified by running the same keys through
vim -u NONE -i NONE -N -es with nofixeol, and diffing the written
bytes. Both flags matter: without -i NONE, viminfo carries registers
between runs and p appears to paste something that was never yanked;
without nofixeol, vim silently appends a trailing newline on write and
two correct results look different.
Decision¶
G stopped counting the position past a trailing newline¶
A buffer ending in \n gets a final m_lineStarts entry at EOF. It is
a valid cursor position but not a line, and G used
m_lineStarts.size() - 1, so it landed one line below the text. On a
3-line file the status bar read Ln 4.
vimLastLine() skips that entry. This was the root of the vGd bug —
the selection ran to a phantom line and took the rest of the buffer with
it — and of the Ln 6 readings in earlier testing that nobody had
questioned.
D with a count takes the line, not just its text¶
vim's rule turns on the cursor's column:
| from | vim |
|---|---|
(2,1) 2D |
aaa / ddd — lines 2 and 3 gone |
(2,2) 2D |
aaa / b / ddd — line 2 keeps b and its newline |
(2,3) 2D |
aaa / bb / ddd |
A counted D starting at column 1 empties the line as well as taking
the ones below, so the line itself goes. From any other column it
survives with what was before the cursor. C does not do this — 2C
collapses the lines into one to type on — so the two stopped sharing
a range.
dw on a line's last word stops at the line end¶
vim: "when using the w motion with an operator and the last word moved
over is at the end of a line, the end of that word becomes the end of the
operated text." dw on the last word of a line empties the line rather
than pulling the next one up.
A charwise delete covering whole lines becomes linewise¶
The rule that explains the rest. 2dw down a column of single-word
lines deletes both lines entirely, not their text — because vim
promotes a charwise delete spanning more than one line to linewise when
it starts at column 0 and ends at a line end.
Without this, the previous rule produces aaa / ` /dddwhere vim
givesaaa/ddd. With it, all sevendw/2dw/3dw/4dw` cases
across two files match.
Linewise paste keeps its trailing newline¶
Pasting a yanked line at the end of a file with no trailing newline
prepended a newline and chopped the trailing one, so yyp on a\nb
gave a\nb\nb where vim gives a\nb\nb\n. The leading newline is
needed; dropping the trailing one was not.
Consequences¶
31 of 31 comparisons against vim now match, covering x, dd, dj,
dk, D, dw, cw, yw, yy, cc, o, O, p, P, u and
counted forms, on files with and without a trailing newline.
Performance is unchanged, measured against the commit ADR 0095's baseline was taken from, same method, same machine:
| baseline | now | |
|---|---|---|
| startup 11 / 60 / 277 KB | 62 / 81 / 122 ms | 61 / 81 / 122 ms |
| RSS 11 / 60 / 277 KB | 44.9 / 48.9 / 55.1 MB | 44.9 / 49.0 / 55.3 MB |
| typing, 277 KB with clangd | 6.8 ms | 6.67 ms |
The first 60 KB startup comparison read 81 → 102 ms and was noise: over nine runs each, the baseline ranged 61–122 ms with a median of 81, and the current build 81–82. A single median is not a measurement when the distribution is that wide.
Scroll now measures 17.0 ms rather than ADR 0095's 16.1 ms. That is ADR 0091, not a regression: the clock follows the display, and a 60 Hz screen rounds to 17 ms. The old figure came from a hardcoded 16.
Two things remain different from vim and are features rather than bugs:
J (join) and text objects (ciw, di() do not exist.