Skip to content

ADR 0097: Marks and macros

Status

Accepted

Context

Marks (m, `, ') and macros (q, @) were the two largest remaining gaps in the modal editing. Both were deliberately deferred mid-stream once before; both are things a vim user reaches for without thinking, and notices the absence of immediately.

They are grouped here because they need the same two pieces of existing machinery — the pending-key mechanism that r and g use, and the key-replay path the dot command already had — and because the questions they raise are the same: what exactly is stored, and what happens when the buffer moves underneath it.

Decision

A mark is a (line, column), like a jumplist entry

m{a-zA-Z} stores the cursor's 1-based line and column, buffer-local. That is the same shape MainWindow::JumpEntry already uses (ADR 0070), and the choice is deliberate rather than convenient: having two different notions of "a remembered position" in one editor would be worse than the drift either of them has.

The drift is real and shared: an edit that adds or removes lines above a mark leaves it pointing at the old line number, where vim adjusts it. Storing a byte offset would move it the wrong way for a different set of edits. Fixing it properly means adjusting every stored position on every edit, which is a change to the buffer's contract and not this one.

` jumps to the line and column; ' jumps to the line's first non-blank. Both check against real vim rather than memory — same file, same keys:

keys vim ase
:3, 13l, ma, G, `a 3,22 3,22
:3, 13l, ma, G, 'a 3,9 3,9

A mark jump records a jumplist entry, so Ctrl+O comes back from it — verified, not assumed. Jumping to a mark that was never set says so rather than doing nothing.

A macro stores key events, not characters

q{a-z} records, q stops, @{a-z} plays, @@ replays the last played, and a count repeats (3@a).

The dot command records a QString of characters, which is enough for what it repeats. A macro is not: Escape and Backspace carry no text, so a macro of plain characters would silently lose exactly the keys that end an insert or fix a typo. Recording keeps the key code, the modifiers and the text, and replay reconstructs a QKeyEvent from all three.

Keys are recorded before dispatch, at the top of keyPressEvent, so a macro replays what was typed rather than what it was interpreted as. The q that ends recording is dropped afterwards — leaving it in would mean every replay started a recording.

Verified against vim by setting the same register contents and comparing the resulting buffer byte for byte, for a count (3@a) and for @@.

Running away is the failure that matters

A macro may call itself — qa j@a q is a legitimate thing to type, and vim runs it until it hits an error. Two limits stop it here: a nesting depth of 32, and a budget of 200,000 replayed keys shared by an entire outermost invocation.

Both say so when they stop. Unwinding silently would look like the macro simply did less than it was asked to, which is the failure mode this codebase keeps finding and removing.

The recorded keys are copied before replay, because a macro can re-record the register it is playing from, and iterating a container while it is rewritten is how that ends.

Consequences

Uppercase marks (A-Z) are global — they name a file as well as a position — and are covered by ADR 0098, which solves it the way this one predicted: by reusing the jumplist's entry.

Marks as operator targets

Added immediately after the above, because a mark you cannot delete to is half a mark. ' is linewise and inclusive of both ends; ` is charwise and exclusive, so the byte under the later position survives. Both reuse the operator helpers the other motions already use.

Checked against vim on the same file, mark at (2,5), cursor at (4,6):

keys result
d'a one AAA / five EEE
d`a one AAA / two DDD / five EEE
c'a then XX one AAA / XX / five EEE
y'a, G, p the three lines appended at the end

All four byte-identical to vim.

The wiring was not where it looked. vimApplyNormalKey abandons a pending operator before its switch — "operator pending, but this is neither a repeat nor a motion" — so ' reached that guard and the d was discarded before the mark case could run. A mark motion has to be recognised above that guard, which is the sort of thing a measurement finds in a minute and reading finds in an hour: the keys arrived correctly, the switch was correct, and neither was the problem.

An operator over a mark that was never set changes nothing and says so.

Macros record what reached the viewport, so keys handled by the window before it — Ctrl+Tab, Ctrl+W, Ctrl+N, the jumplist bindings — are not captured. A macro that switches buffers mid-replay is not something this supports.

Dot-repeat was re-checked against vim after this landed, since macro recording hooks the same key path it does: 2x, j0, . produces the same buffer in both.