Skip to content

ADR 0104: The vim conformance suite

Status

Accepted

Context

The vim layer is 2,347 lines in one file — the largest and most intricate part of the editor — and had no automated coverage at all. The nine existing suites are all in core/ and modules/; gui/tests/ did not exist.

Its behaviour had been verified, repeatedly and carefully, by running the same keys through real vim and diffing the bytes. Roughly 150 such comparisons were made across the work that produced ADR 0097 through ADR 0102. Every one of them was a throwaway script in a scratch directory. None survived the session that made it.

The evidence that this mattered is in the bug pattern. The phantom m_lineStarts entry past a trailing newline caused three separate bugs (G, vGd, J). The guard in vimApplyNormalKey that abandons a pending operator caught three separate features (marks, d'a, text objects). The same defect kept resurfacing because nothing pinned the behaviour down between sessions.

Decision

In process, not through the GUI driver

The GUI driver used for manual verification is deliberately untracked, so a committed test cannot rely on it. That constraint turned out to be a favour: the suite constructs a real EditorViewport, sends QKeyEvents to it, and reads the buffer back. No VNC, no screenshots, no sleeps. 91 cases in 0.03 seconds, against several seconds per case through that driver.

gui/ now builds an ase_gui_objects object library that both the executable and the test link, so the test drives the same widgets the user does rather than a second copy of the sources. main.cpp stays with the executable, since it carries main().

Expectations are generated from vim, and committed

derive_cases.py runs each case through vim -u NONE -i NONE -N -es with nofixeol and writes vim_cases.inc. That file is committed, so the suite needs no vim to run — but the expectations are not one person's memory of what vim does, and anyone can regenerate them to audit or extend.

Both vim flags are load-bearing and both produced a wrong answer during this work: without -i NONE, viminfo carries registers between runs and p appears to paste something never yanked; without nofixeol, vim appends a trailing newline and two identical results look different.

Four cases vim cannot be scripted into producing

vim -es refuses to record macros inside :normal — after qaxjq the register comes back empty, so @a does nothing and the derived expectation is silently wrong. The first full run "failed" three macro cases where the editor was right and the generator was not.

Those four live in a MANUAL_CASES list with the reason written next to each, rather than being quietly dropped. The fourth is a file with no trailing newline, where our buffer terminates the last line and vim's does not — visible only under nofixeol, since vim's own default writes the same bytes we do.

Consequences

The suite found a bug on its first full run. j on the last line moved onto the position past a trailing newline, where vim stays put — the fourth bug from that same phantom entry. It is fixed the way h and l were: the vim path clamps to vimLastLine(), the arrow keys are left alone, because that position is a real navigable line in the non-vim editing profile and is not one in vim.

112 cases in two tables. 91 key sequences cover deletes and counts, D/C asymmetry, word motions under operators, the charwise-to-linewise promotion, h/l and j clamping, yank and paste, undo, open and replace, J and gJ spacing, every text object, marks as motions and as operator targets, macros, and . repeat.

19 more cover : commands, which arrive through runCommand rather than the key dispatch and so get their own table: :s with ranges, g and i flags, an alternate separator, &, and the vim-dialect patterns from ADR 0102 — \(grouping, bare parens as literals, \+ against +, word boundaries, alternation.

Regeneration is idempotent: running the generator twice produces byte-identical files, so a committed generated file never churns a diff.

Not covered: anything needing a panel — completion, hover, the find bar — since the window installs those on the viewport, and a test that built a MainWindow would be testing the wiring rather than the behaviour.

Verified that the suite fails when it should: reverting the j fix turns it red on the exact case, with the differing bytes printed.