Skip to content

Developed v2.3.0 - #9

Merged
michal-ruzicka merged 48 commits into
mainfrom
devel
Sep 2, 2026
Merged

michal-ruzicka merged 48 commits into
mainfrom
devel

Conversation

@michal-ruzicka

Copy link
Copy Markdown
Owner

No description provided.

michal-ruzicka and others added 30 commits August 28, 2026 11:12
On Windows every line of the hex view carried a trailing ^M, on every
page load and every page turn, cleared by :HexPairRefresh and back again
at the next <Leader>j.

xxd opens a dump in TEXT mode there - xxd.c has
BIN_ASSIGN(fpo = stdout, revert) for the stream and BIN_WRITE(revert)
for a named output file, so binary is what a REVERSE gets and a dump
gets "wt" - and every dump line therefore reaches Vim CRLF-terminated.
s:LoadPage() was the one place in this plugin that put xxd's output into
a buffer through a '%!' filter, which leaves what becomes of that CR to
'fileformats' auto-detection. That is the USER's option, not ours: with
`set fileformats=unix` in a vimrc nothing strips it. Refresh looked like
the cure only because s:ToHexView() already reads its dump with
readfile(), whose text mode drops a CR before a NL whatever the options
say.

So the loader now does what every other xxd call in this file already
did: xxd writes to a file, readfile() reads it, s:SetLines() and
s:HexViewLines() build the view - the same two helpers the toggle uses,
so the two ways into the hex view can no longer differ about its shape.
Cosmetic only, as it turns out: the CR sat past the ASCII column, in the
region invariant 1 ignores, so it never reached a file. It was just on
screen the whole time.

Two things come with it.

The read moved AHEAD of the page state. Every b:hexpair_page_* assignment
says "this buffer is showing page N" and the write path believes them,
so a read that failed half way used to leave the buffer's bytes and its
idea of which page they are disagreeing - and a :w would have patched the
old page's bytes in at the new page's offset. Nothing about the buffer is
touched now until the dump is in hand and its shape has been checked.

And the shape check counts the lines xxd produced rather than the lines
the buffer ended up with, so it no longer has to add the banner and the
ruler back on to know what to expect. That is the one line-to-offset
mapping in the plugin that does NOT go through s:HeaderLines(), and must
not; invariant 7 says so now.

Reproduced first as a failing test, per CLAUDE.md. Windows CI runs it
against the real xxd.exe, which needs no help; everywhere else a stand-in
supplies the CRLF, since the platform's own xxd will not - same output
CRLF-terminated to stdout and to a named output file alike, and untouched
for -r. Three of its five checks fail without the fix; the two that pass
either way are the counter-cases that pin the diagnosis - a refresh was
never affected, and the file never was.

Measured while there: ten default-size page turns over an 8 MiB file,
0.35 s before and 0.30 s after. The filter had to serialize the buffer
into a temp file of its own before running; this does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vimhex and vimhexdiff existed for anyone with a shell function and for
nobody else. On Windows the way in was to remember the -c line, which is
the thing hexpair.bashrc exists so that nobody has to.

So: vimhex.cmd and vimhexdiff.cmd, same names, same arguments, same
behaviour - a page number, $, +N/-N, @byte, piped input, two files side
by side. They are not translations of the shell functions so much as the
same design written twice: both pass the paths through the ENVIRONMENT
into $NAME inside a Vim expression, because a name holding a space or a
literal '$' does not survive :execute's own argument parsing, and both do
everything from VimEnter, the only point that is after the content has
arrived whether it came from a file or from standard input. Verified by
running the exact Vim command lines they build, with a fixture called
"big bin $HOST.img".

VIMHEX_VIM picks the Vim and defaults to the console `vim`, which is what
typing this into a cmd window asks for; gvim is a word away.

Where to put them is a real question on Windows and the README answers
it: on PATH, and the plugin's own directory is the tidiest thing to point
at, since updating the plugin then updates the commands. With the warning
that belongs next to it - `setx PATH "%PATH%;..."` writes the system and
user paths joined together into the user's own Path and truncates it at
1024 characters.

The context menu is where this stops being a translation.

One file is one registry key under HKEY_CURRENT_USER: no administrator,
nobody else's account touched, and deleting the key undoes it. Snippets
in README.md rather than a .reg in the tree, because the path in them is
the reader's own.

Two files cannot be one click. Explorer runs a verb ONCE PER SELECTED
FILE, each invocation getting its own %1, and there is no %2; collecting
a whole selection into one invocation needs a DropTarget or
ExplorerCommand COM handler - a registered in-process server, which is a
different kind of project from a batch file. So vimhexdiff.cmd grew the
two steps every diff tool on Windows solves this with: /pick FILE
remembers the left-hand side, /with FILE compares against it. The
remembered path is forgotten as soon as it is used, so the next
comparison starts from a fresh pick instead of silently reusing a stale
one, and it is stored quoted so a name with an & survives the round trip
through the file.

Both files ship: pack-release.py's FILES, and the packaging test's rule
widened from .md/.vim/.txt/hexpair.* to include vimhex*.cmd - that rule
exists precisely because hexpair.vimrc was once in the repo and the docs
and not in the tarball. vimhex*.cmd and not *.cmd: pack-release.cmd
builds a release rather than being part of one.

Two things are worth knowing before touching these again. CRLF line
endings are load-bearing and .gitattributes already pins them; a batch
file with LF endings breaks `goto` under cmd.exe. And CI does not run
them - the Windows job drives the Vim suite under Git Bash - so the
batch itself has only ever been read, not executed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vimhex.cmd/vimhexdiff.cmd and the vimhex/vimhexdiff bash functions default
to console Vim; there was no direct way to launch gVim, and every path that
matters for a GUI (a double-click, a context-menu verb) has no console to
run the console Vim in and no way to pass VIMHEX_VIM in anyway.

- gvimhex.cmd / gvimhexdiff.cmd: the same two Windows commands, defaulting
  VIMHEX_VIM to "gvim" instead of "vim". Each delegates to its
  vimhex.cmd/vimhexdiff.cmd counterpart (via %~dp0) rather than duplicating
  the argument parsing and, for the diff pair, the /pick-/with state
  handling - one source of truth for that grammar, at the cost of no
  longer being independently relocatable: the two pairs must be copied
  together.
- gvimhex/gvimhexdiff in hexpair.bashrc: the same idea for bash, delegating
  to vimhex/vimhexdiff with VIMHEX_VIM defaulted the same way.
- vimhex-contex-entry.add.reg / .remove.reg: ready-made versions of the
  registry snippets README.md already spelled out by hand for the Explorer
  context menu (open, plus the diff /pick+/with pair) - one import wires up
  all three entries, the other removes them. They point at
  gvimhex.cmd/gvimhexdiff.cmd rather than the console variants, since a
  context-menu verb is exactly the case those exist for. README.md's own
  inline snippets are updated to match, so the shipped files and the docs
  describe the same thing.

Both new file families are bundled in the release tarball: pack-release.py's
FILES list gets four new entries, and the packaging-list test's glob widens
from 'vimhex*.cmd' to '*vimhex*.cmd' plus a new '*vimhex*.reg' (the leading
'*' catches gvimhex.cmd/gvimhexdiff.cmd and the vimhex-contex-entry.*.reg
pair without a separate rule, and still excludes pack-release.cmd, which
names no release file). .gitattributes gains a *.reg eol=crlf rule matching
the existing *.cmd one - Windows-only files, kept CRLF for consistency, even
though Registry Editor itself tolerates LF better than cmd.exe's goto does.

Neither the .cmd nor the .reg files are exercised by the automated suite,
matching the existing vimhex.cmd/vimhexdiff.cmd precedent (the Windows CI
job runs under Git Bash and never touches cmd.exe or regedit); the bash
wrappers were checked by hand with stand-in vim/gvim scripts on PATH. All
477 existing checks in test/run-tests.sh still pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tries

Two problems reported after actually trying the Explorer context-menu
entries added in the previous commit: the console window flashes and
closes before anything in it can be read, and the entries have no icon.

- vimhex.cmd/vimhexdiff.cmd (and by delegation gvimhex.cmd/gvimhexdiff.cmd)
  now check `errorlevel` right after starting Vim and, only on a nonzero
  exit, echo why and `pause` before exiting - Explorer's "cmd.exe /c"
  closes its console the instant the batch ends, too fast to read the
  one-line "not recognized" cmd.exe prints when VIMHEX_VIM does not
  resolve. A normal launch is unaffected: gVim forks and returns control
  (0) almost at once, console Vim returns when the user quits it, both long
  before this line would run, so the documented fast flash on success stays
  exactly as fast.

  Root cause of the original report NOT confirmed - reported separately
  that vimhex*.cmd run directly from a cmd prompt work first try, which
  rules out a bad VIMHEX_VIM/PATH in that shell and points at something
  specific to the Explorer-constructed "cmd.exe /c" invocation instead. Its
  quoting was re-derived by hand against cmd /?'s own two documented rules
  and comes out correct (6 quote characters, not the 2 rule 1 needs, so
  rule 2 applies: strip the outer pair, leaving a validly quoted path and
  %1), so that is not the leading suspect either. This commit makes the
  actual failure message visible instead of guessing further blind -
  CLAUDE.md records what was ruled out for whoever picks this up next.

- vimhex-contex-entry.add.reg: each `\shell\<verb>` key gets an "Icon"
  value pointing at gvim.exe - a second placeholder path (Vim's own
  install directory, not the plugin's, so distinct from the existing
  install-path placeholder), since Explorer has nothing to show for a verb
  whose command is a .cmd file otherwise. .remove.reg is unchanged: it
  deletes the whole key, Icon value included.

README.md's inline registry snippets and doc/hexpair.txt are updated to
match. All 477 checks in test/run-tests.sh still pass; pack-release.py
still packages all sixteen files cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Explorer has nothing to show for a verb whose command is a .cmd file, and
the previous commit's workaround - pointing "Icon" at the user's own
gvim.exe - worked but needed a second, unrelated placeholder path (Vim's
own install directory) on top of the plugin's. This replaces it with three
real icons, designed after a round of visual feedback.

icons/rasticon.py is a from-scratch vector rasterizer plus a PNG/ICO
encoder, stdlib only - no Pillow or ImageMagick available to build with,
and the plugin's own no-incidental-tooling rule fit the situation anyway.
icons/design.py draws the three icons from it: a V mark in Vim's own green
(original, not extracted from a real gvim.exe - there is no Windows box
handy to pull one off of), a "0x" badge (bottom-right, smaller) marking
these as hexpair's, and on the diff pair a bigger bottom-left badge of two
window panes - an echo of vimhexdiff's own actual `vsplit` - blue left /
orange right, the side that entry represents at full colour and the other
dimmed. That replaces an earlier version compared side by side in a
gallery (top-right diff mark, top-left L/R letter or arrow): neither text
nor an arrow survived down to 16px, colour still does. icons/build.py
renders the three .ico files (16/24/32/48 px plus 256); it is
deterministic, checked by hashing two consecutive runs.

Only the .ico output belongs in what a user gets - the generator is a
development file. pack-release.py's FILES gets the three .ico paths, not
the .py sources; test/run-tests.sh's packaging-list glob gains a bare
`*.ico` (deliberately asymmetric with the existing `*vimhex*.{cmd,reg}`
rule: every .ico ships, but icons/*.py matches no pattern there and so is
correctly never expected in FILES). icons/__pycache__/ is gitignored.

vimhex-contex-entry.add.reg's "Icon" values now point at
icons/hexpair-{open,pick,with}.ico under the same plugin-path placeholder
the "command" lines already use, rather than a second one. README.md,
doc/hexpair.txt and CLAUDE.md are updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The .reg files shipped with a C:\Users\you\... placeholder that every user
had to edit in six places before importing. Requested: a default install
must import and work as-is.

The obvious fix - write %USERPROFILE% into the paths - does not work in a
plain REG_SZ value: the shell would look for a folder literally named
"%USERPROFILE%". Environment variables in a registry value need
REG_EXPAND_SZ, and the .reg text format can only express that type as
`hex(2):` followed by the string's UTF-16LE bytes. That is not something to
maintain by hand, so the two files are now generated by
make-context-entry-reg.py, with a "do not edit by hand" banner, and it
takes an optional install path so "installed somewhere else" is a re-run
rather than an edit.

The expansion order is what makes %1 survive this, and is written down in
the generator because it is the part that could silently break: the shell
expands the variable when it READS the value and substitutes %1 only when
it builds the command line. Once %USERPROFILE% is consumed as a pair, the
one remaining % in the string is %1's own - a lone %, so it cannot be
mis-paired into a bogus variable name. Verified by decoding the generated
hex back to UTF-16 and comparing against the intended string, for all six
values; also verified the generator is deterministic (two runs, same
hashes) and that a custom path argument comes through.

Note the default is %USERPROFILE%\vimfiles - "vimfiles" without an
underscore. It is _vimrc for the file but vimfiles for the directory, and
getting that wrong would produce entries that silently point nowhere.

pack-release.py is unchanged: the .reg files still ship, and the generator
is development-only - it matches no pattern in the packaging test's
shipped-files glob, so it is correctly never expected in FILES. README.md,
doc/hexpair.txt, CHANGELOG.md and CLAUDE.md updated; the README's inline
snippets now show the decoded values rather than a hand-editable REG_SZ.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four things reported from actually using the entries on Windows.

A "vimhex" submenu instead of three top-level items. The folder is a verb
carrying "ExtendedSubCommandsKey" and no \command of its own; the key it
names holds the children under its own \shell. That indirection is what
keeps everything in HKEY_CURRENT_USER - the older "SubCommands" scheme
resolves its verbs against HKLM's CommandStore and would need administrator
rights. Children sort alphabetically by KEY name rather than by write
order, hence the 10-/20-/30- prefixes, and the separator between "gvimhex
this" and the diff pair is "CommandFlags"=dword:20 (ECF_SEPARATORBEFORE) on
the item below the rule.

No console window, and no stolen focus. This was the real complaint: the
flash was the visible half, but with a Windows Terminal window already
open, the console a "cmd.exe /c" verb creates attaches to it and TAKES THE
FOCUS, so Total Commander - where the menu was being used - ended up in the
background and had to be clicked back into. Every entry now runs
wscript.exe on the new vimhex-launch.vbs, a GUI-subsystem program, so no
console is ever allocated; the .cmd runs with window style 0. Two details
in there are load-bearing and easy to undo by accident: output is
redirected to a log file rather than captured with .Exec(), because .Exec()
is precisely what would create the console this exists to avoid; and the
launcher sets HEXPAIR_NO_PAUSE, because a "pause" behind a hidden console
waits forever where nobody can see or dismiss it.

A real message instead of a flicker when no left-hand file is picked. The
.cmd files gained :holdopen (pause only when HEXPAIR_NO_PAUSE is unset), so
a hidden run reports through the launcher's message box and a run from a
real console still pauses. The :nopick and :gone texts now say which menu
entry to use rather than just naming the flag.

vimhexdiff opens maximized. Two hex views side by side want the width
anyway, and a narrow window was also what made Vim stop for a hit-enter
prompt on every file it opened: a long path plus the size makes the file
message longer than one line, which is what triggers that prompt. It sets
'shortmess'+=F and maximizes with the Win32 GUI's own :simalt ~x, both
inside a VimEnter autocommand and guarded by has('gui_running') - :simalt
exists only in the GUI build and has to run once the GUI is actually up.

The generated .reg is now covered by three tests rather than by assertion
in a comment: every hex(2) value is decoded back to UTF-16 and checked to
round-trip NUL-terminated, every command is checked to go through the
launcher, and the lone-% count is checked to be exactly one. That last one
is the property that keeps %1 from being swallowed into a bogus variable
name, and it needed re-checking here because the commands grew from one
%VAR% to three. Confirmed the checks can fail, by mutating a command back
to cmd.exe and watching the suite reject it. 480 checks, up from 477.

Recorded in CLAUDE.md as an accepted risk rather than passed over:
VBScript is deprecated on Windows, a Feature on Demand since 11 24H2 with
removal announced for later. The replacement when it goes is a small
GUI-subsystem stub .exe, which means shipping a compiled binary - which
this project so far does not do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Selecting the two sides of a diff no longer has an order. /pick and /with
become /left and /right; each records its own side and stops, and whichever
completes the pair opens the comparison and clears both selections, so the
next diff starts from a clean slate. Selecting the same side twice
overwrites it - changing your mind about one half is not a failure and says
nothing about the other half.

The point is not only convenience: "you have not picked a left-hand file
yet" was an error that existed purely because the protocol had an order to
get wrong. With the order gone, so is the error. What is left in :gone is a
real one - the file selected as the other side has since been moved or
deleted - and it now clears both selections before reporting, so the advice
to select the two files again is actually true.

Menu captions follow: "gvimhexdiff select as left" / "select as right".

The state machine was transcribed into Python and exercised over the
sequences that matter (either order; re-selecting one side; two diffs in a
row; a vanished file) before any of it was written up - the batch itself
cannot be run here, and "symmetric" is exactly the kind of claim that is
easy to write and easy to get subtly wrong.

Also withdraws vimhex-launch.vbs, added in the previous commit. It did work
- wscript.exe is GUI-subsystem, so the console never appeared and the focus
never moved - but on the maintainer's question about antivirus, the honest
answer is that "run this command with a hidden window" through the Windows
Script Host is one of the shapes heuristics look for, and VBScript is being
removed from Windows (Feature on Demand since 11 24H2). The only other way
to lose the console is a GUI-subsystem stub .exe, and an unsigned binary is
usually worse for antivirus than the script, besides being the first
compiled artefact this project would ship. So the flash and the focus theft
are accepted, deliberately, and CLAUDE.md records the reasoning under a "do
not re-litigate without new information" heading - along with the one
untried idea worth a look if it is ever revisited: have the registry call
gvim.exe directly, a GUI program with no console to begin with.

What survives from the launcher and stays useful: the exit-status check in
each .cmd, and HEXPAIR_NO_PAUSE, now documented as the hook for a caller
that shows the message its own way rather than as the launcher's private
flag.

The registry test follows the commands back to cmd.exe and additionally
pins that the two diff entries pass /left and /right. 480 checks, unchanged
in count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported: when the file selected as the other side has since been moved or
deleted, clearing BOTH selections is wrong. Nothing happened to the side
just selected - the file is sitting right there - so requiring it to be
selected again is asking the user to redo work that nothing went wrong
with.

The existence check now runs before either selection is deleted, and :gone
removes only the stale one. The kept side is named in the message, so it is
clear the next step is a new partner for it rather than starting over; the
simulation covers exactly that - after the error, one selection completes
the pair and the comparison opens with no re-selecting.

The success path is unchanged and still forgets both, which is the same
rule seen from the other side: a selection is only ever dropped for a
reason that applies to it. Both are spent when a comparison actually opens;
a file that has gone is spent on its own.

Also covers the mirror case, where it is the right-hand selection that
vanished and the left one that survives - the sides are symmetric here too,
and a fix that only worked one way round would be easy to write.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.remove.reg carried deletions for three top-level "*" menu keys, from the
shape this menu had before the submenu landed. That cleanup was written for
an upgrade path that does not exist: every one of those shapes came and
went inside unreleased v2.3.0-devel, so no user has them installed and
there is nothing to tidy up after.

Removing it leaves .remove.reg saying only what it means - delete the
"vimhex" verb and the key holding its children - instead of also carrying a
history lesson about layouts that were never shipped.

CLAUDE.md records the rule rather than just the edit, since the same
question will come up at the next restructure while v2.3.0 is still
unreleased: do not add deletions for a layout no user ever had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported: a missing left-hand file was announced, a missing right-hand one
was not. The cause was that the check and the message both hung on
HEXPAIR_THIS/HEXPAIR_OTHER - on which entry had just been used - rather
than on which side the file actually is. Only the remembered side was ever
checked, so whether a stale selection got caught depended on the order the
two entries happened to be used in, and the message named the side in the
same click-relative terms.

The fix is to stop talking about "this" and "other" as soon as both paths
are known. The block now names HEXPAIR_LEFT_FILE and HEXPAIR_RIGHT_FILE
once, and everything after that is in terms of sides: both are checked, by
name, and :goneleft/:goneright hand :gone a consistent set of names to
report. Symmetry is then a property of the structure rather than something
to verify by reading.

That also closes a case neither side covered before: the file just clicked
is now checked too. A file manager showing a listing it has not refreshed
will happily hand the entry a path that is no longer there, and that
deserves the same answer as a selection that went stale while remembered.

Only the missing side's selection is cleared, either way - the surviving
one is kept whether it is the new selection or the remembered one.

The simulation grew all four combinations, since the whole point is that
these must not differ: remembered-left gone, remembered-right gone,
just-clicked-left gone, just-clicked-right gone. Each checks the side
named, the selection kept, and - for the first two - that naming a new
partner for the survivor opens the comparison with nothing re-selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Auditing for v2.3.0 rather than changing behaviour. Nothing here touches
code that runs; the version number is left alone deliberately.

The help disagreed with the mappings the plugin actually ships. It claims
its example list IS hexpair.vimrc, so the two were compared mechanically,
and three entries were out:

  - <Leader>P was documented for <Plug>(HexPairPageGotoForce), but both
    hexpair.vimrc and README.md use <Leader>G. v2.2.0's own changelog
    entry records that move; the help was simply never updated with it,
    so anyone following :help got a key the shipped vimrc does not bind.
  - <Leader>= / <Plug>(HexPairSyncViews) was missing entirely.
  - <Leader>I / <Plug>(HexPairInsertChar) was missing entirely.

Both of those also had no *<Plug>(...)* tag, so :help could not reach
them; they are in the tag block now. README.md needed nothing - it already
matched hexpair.vimrc line for line.

The demo directory was renamed docs/ -> demo/ before v2.2.0 shipped, but
nine references in CONTRIBUTING.md, one in CLAUDE.md and two in the v2.2.0
changelog entry still named the old path - including the runner command a
contributor would copy.

CONTRIBUTING.md's repo layout table had drifted further: it listed none of
the four Windows .cmd wrappers, neither .reg file, make-context-entry-reg.py
or icons/. All are in now, each saying whether it ships in the tarball.

hexpair.bashrc still described the diff state as "pick/with", from before
the sides became /left and /right.

What the audit checked and found already right, so it does not get quieter
next time: all 32 commands documented and no documented command that does
not exist (asked of Vim itself, not grepped); every <Plug> target now
tagged; helptags builds; both generators reproduce their committed output
byte for byte; the tarball is byte-identical across two builds and holds
the 19 files it should; shell files pass -n; the plugin and hexpair.vimrc
source cleanly under -u NONE; no TODO/FIXME anywhere.

The Vim 8.0 floor was run rather than assumed, which CLAUDE.md asks for:
447 ok, 33 failures, all of them the documented splice and save-as set,
and no E... from Vim anywhere - so those are clean refusals. Two
corrections to that recipe, which no longer works as written: configure
needs -std=gnu89 on a current compiler (its uint32_t probe is a K&R main()
calling exit() with no <stdlib.h>, and configure misreports the rejection
as "uint32_t not defined correctly"), and `make vim` has to run from src/,
since 8.0's top-level Makefile has no such target. Also recorded that a
refusal reads as an unchanged file in the log, not as the gate message -
which is what someone verifying this next will otherwise hunt for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…here

Reported on a 120 GiB file compared with a much smaller one: going to a
page past the smaller file's end showed bytes as NOT differing. Every byte
of such a page differs - there is nothing there to match - so the page was
claiming agreement with a file that does not reach it.

Three guards asked s:DiffHex() whether to do anything, and read its empty
string as "no comparison is running". For a page beyond the other file's
end that string means something else entirely: "that file has no bytes
here". So the hex marking (s:DiffHighlight), the count (s:DiffCount) and
the text view's marking (s:TextComparePositions) each gave up exactly where
they had the most to say. The workers underneath were all correct already -
strpart() past the end returns '', which differs from any real byte, and
both run builders extend a difference over the tail - so this is only ever
the guards.

They now ask HexPairPagedDiffActive(), one predicate over the diff FILE,
which is the only thing that can distinguish "not comparing" from
"comparing against nothing". One predicate rather than three conditions so
they cannot drift apart again; global because it is the testable half - the
drawing itself needs a window with geometry, which vim -es does not have
(line('w$') comes out above line('w0') there).

Confirmed the tests catch it by reverting the fix: the count and text-view
checks fail, and the marking-positions one does not - that path was never
broken, which is worth knowing if this is ever touched again.

Adds :HexPairDiffShow, asked for alongside: what the other file actually
holds at the cursor, or over a Visual selection. The marking says which
bytes differ and no more, and on a page past the other file's end every
byte is marked with the reason invisible - this is what names it. Missing
bytes print as `--` rather than blank, so an absent byte cannot be read as
a byte that happens to be 00, and a selection is summarised with the
listing stopping after 32 bytes. The wording is a pure function, so all six
shapes of it are tested without a cursor or Visual mode.

It reports the page as READ rather than as the buffer holds it, matching
:HexPairDiff's counts: this is about how the two files compare, which
unwritten edits are no part of.

<Leader>D in hexpair.vimrc, Normal and Visual, as suggested. The <Plug>
coverage test caught the missing key before I did.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported: ':saveas' wrote the file but left the buffer modified, and a ':w'
after it did not help. Two faults, and the second is the worse one.

Vim does not clear 'modified' for an acwrite buffer - the BufWriteCmd owns
that flag (:help BufWriteCmd) - and this one only cleared it on the
piped-input path, which was the only case that had ever needed it.

And the view went on believing it edited the file it came from. Since the
buffer had been renamed, every later write compared the target against a
file that was no longer this buffer's and took the "save a copy elsewhere"
path: the whole file rewritten, again, instead of a page patched. On the
sizes this plugin exists for, that is an expensive way to be wrong - and it
left the buffer's identity and the plugin's disagreeing, which is the same
class of fault as the <amatch>-versus-bufname() one already in the table.

':saveas' now adopts the file it wrote - the ending that already existed
for piped input, which wanted exactly the same thing - and ':file {name}'
followed by ':w' does the same. ':w {file}' is untouched: still a copy,
still leaving the buffer alone and still modified, because it is.

Telling them apart is the whole trick. Inside BufWriteCmd, ':saveas' and
':w {file}' both arrive with <amatch> set to the target; what differs is
that ':saveas' RENAMES THE BUFFER BEFORE WRITING, so bufname('%') answers
it (s:IsSaveAs()).

Worth recording, because I wrote it and it looked right: the obvious fix of
keeping the cached buffer name current from BufFilePost is wrong and
actively dangerous. That event fires BEFORE the write, so the cache then
says the target IS this view's own name, the write takes the plain
page-patch path, and the page is patched into the file it was being saved
away from. The cache's job is to remember the name that goes with
b:hexpair_page_file, not to track the buffer. Caught by the repro before
it went anywhere near a commit; CLAUDE.md's table carries the warning.

Six tests, covering both commands and the case that must NOT change:
':saveas' clears the flag and adopts, a later ':w' patches the adopted file
and stays clean, the last edit is in it, the file it was saved away from is
untouched, ':file' alone writes nothing and keeps the original, and the
':w' after ':file' adopts. Confirmed they fail on the unfixed code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported: a 120 GiB file compared with a 77 GiB one showed bytes as
matching on pages wholly past the shorter file's end - on Windows only,
correct under WSL on the same two files with the same build. The
empty-theirs fix was already in on both, so that was not it.

xxd carries its seek offset in a C long from end to end: strtol() into
`long seekoff`, then fseek(). On Windows a long is 32 bits (MSVC and MinGW
are both LLP64), so the limit is 2 GiB - and strtol() SATURATES rather than
failing, so an offset past it does not error, it silently becomes
2147483647 and xxd reads a page from there. Both files were being read at
2 GiB, where the shorter one does have data, so the comparison came out
true for bytes that happened to agree there. Under WSL a long is 64 bits and the
same code is right, which is exactly the shape of the report.

Confirmed from xxd.c rather than guessed: `long length = -1, n = 0,
seekoff = 0` and the strtol/fseek pair are still there in current master,
and the reverse path (`base_off`, `want_off`, `have_off`, fseek) is the
same.

Reads past the limit now go through readblob(), which takes a 64-bit
offset, returns nothing at all past the end - which is the answer the diff
needs - and is the same call the splice path already reads with. It is 44%
slower per megabyte than xxd, measured here, which is why it is used only
past the offsets xxd handles correctly rather than everywhere.

Writes past the limit are refused. `xxd -r` has the same 32-bit offsets and
Vim has no primitive to write at an offset to fall back to, so there is
nothing to switch to: it was patching the page in at 2 GiB, over whatever
was there. A refusal loses an edit; the alternative loses the file.

Gated on has('win32') and not on a probe, because a probe is not available:
strtol saturating means a too-large offset and a merely-past-the-end one
are indistinguishable from outside - both give nothing on a small file. A
32-bit Unix build has the same limit and is recorded rather than guessed at.

The offsets that pick the blob reader need a 2 GiB fixture, so what the
tests pin is the property that would rot silently instead: that both
readers give the same bytes for the same range, that the blob one comes
back as flat lowercase hex (string() of a Blob is uppercase, with a '.'
every four bytes), and that past the end it gives nothing rather than
something.

Also adds '$' to :HexPairGoOffset for the file's last byte, asked for
alongside: the same shorthand :HexPairPageGoto already took for the last
page, and the two prompts sit under neighbouring keys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit fixed the reads the comparison uses and left the one
the user actually looks at: the page dump is a SECOND xxd call, with the
same -s and so the same 32-bit clamp on Windows. Reported straight back -
paging backwards from the end of a 120 GiB file showed the same content
over and over, because every page past 2 GiB was the bytes at 2 GiB.

Fixing it by handing xxd the right bytes with -o does not work either: the
display offset is an unsigned long in xxd as well, so the offset column
would wrap at 4 GiB even when the bytes were right. So past the limit the
dump lines are built here, by HexPairPagedDumpLines().

That means reimplementing xxd's output format, which is only safe if it is
checked against xxd rather than against a description of it - so the suite
does exactly that, over full lines, a short last line, a page shorter than
one line, and two bytes-per-line widths, since the padding of a short line
is what a hand-rolled formatter gets wrong. Plus an offset past 32 bits,
where the column widens and no xxd on Windows could have produced it.

Checked the remaining xxd calls while here: the other `-s` ones are all on
the write path, which this platform now refuses past the limit, and the
rest are `xxd -r -p` with no seek at all. s:CopyRange() was already reading
through readblob().

Also makes the byte prompt say '$', which it takes but did not mention -
the page prompt under the neighbouring key lists it, and a prompt that
names one and not the other teaches the wrong thing about its neighbour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Asked whether has('win64') needs handling too. It does not, and the reason
is worth having in the file rather than rediscovered: win32 is true for
every Windows Vim - "MS-Windows 95 and later, 32 or 64 bits" in Vim's own
feature list, guarded by a plain #ifdef WIN32 - and win64 is an additional
feature on 64-bit builds, not an alternative to it. Asking for both would
be redundant; asking for win64 instead would miss 32-bit builds, where the
limit applies just as much.

The deeper reason is that the limit is a property of the platform's data
model rather than of the build: Windows is LLP64, so a long stays 32 bits
in a 64-bit Vim too, and xxd's seek arithmetic with it stays capped at
2 GiB. "Am I on Windows" really is the whole question.

Comment only - no behaviour change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ming

The readblob() fallback added two commits ago does not work on Windows
either, which the report showed: jumping to the last page of a 120 GiB file
gave "produced 0 lines, expected 5849 - is xxd working?". xxd was working;
readblob() was returning nothing.

read_blob() in Vim's blob.c declares a plain `struct stat` and calls plain
fstat(), where the rest of Vim uses stat_T - `struct _stat64` on Windows,
with a comment in vim.h saying exactly why. st_size is a 32-bit _off_t in
the plain one, so for a file this size the computed length goes negative
and read_blob() takes its `if (size <= 0) return OK;` path: an EMPTY BLOB
AND SUCCESS. No error, nothing to catch. That looks like a Vim bug rather
than a decision, and it is worth reporting upstream.

My own fault made it undiagnosable: s:BlobHex() caught everything and
returned '' , so a real failure and "past the end of the file" - which is a
legitimate empty answer the diff depends on - were indistinguishable. It
now measures what the file actually holds at that offset and only treats a
short read as normal when the file really is that short; anything else
throws, naming both limits.

So native Windows cannot read past 2 GiB at all, not just write. The README
said the plugin behaves the same everywhere and that an 8 TiB file costs
what an 8 KiB one does; that is no longer true without qualification, and
overclaiming it in the file a user reads first is worse than the limit.
There is now a "Windows and the 2 GiB limit" section saying plainly that
past 2 GiB it is not even a viewer, since the reads a viewer needs are the
ones that fail, and that WSL has neither limit.

Also states what was asked and had been left implicit: macOS and the BSDs
are fine, and for the same reason Linux is - LP64 makes `long` 64 bits, so
xxd's seek is, and their struct stat.st_size is a 64-bit off_t, so
readblob() is. Windows is the outlier because it is LLP64 AND because
MSVC's plain struct stat is 32-bit. A 32-bit build of anything shares the
xxd half; that is recorded rather than detected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The readblob() fallback was dead code: Vim's read_blob() declares a plain
`struct stat` and calls plain fstat(), where the rest of Vim uses stat_T
(`struct _stat64` on Windows, with a comment in vim.h saying why), so for a
large file it computes a negative length and returns an empty blob AND
success. It is removed rather than left to mislead the next reader.

What replaces it is PowerShell: .NET's FileStream.Seek takes an Int64, so
it can do what neither xxd nor readblob() can here. This plugin otherwise
refuses to depend on external tools, and that rule is worth keeping - but
this is Windows-only, Windows ships it, and the choice is against not
reading the file at all rather than against xxd. Probed once per session by
running it, not by asking $PATH, since what matters is that it starts.

Details that are load-bearing rather than incidental:

- Arguments go through the ENVIRONMENT and the bytes come back through a
  temp file, never the command line - a path with a space, a quote or an &
  does not survive being quoted through Vim's 'shell' into PowerShell's own
  parser. Same trick vimhex.cmd and hexpair.bashrc already use.
- The script uses single quotes throughout and no & or %, so there is
  nothing in it for shellescape() or cmd.exe to mangle.
- -Command rather than -File sidesteps the execution policy, which applies
  to script files and not to a command string.
- The read is a LOOP: FileStream.Read may return fewer bytes than asked
  for, and a page that quietly stopped half way is the failure this whole
  path exists to prevent.
- The result is checked to be hex and to be as long as the file can supply,
  so a BOM, a stray warning or a partial write cannot reach the dump as
  plausible-looking rubbish.

HexPairPagedDumpLines() stays and is not dead: whatever does the reading,
the dump still has to be formatted here, because xxd's -o display offset is
a 32-bit unsigned long too.

The test now compares the fallback reader against xxd over a range both can
reach. That runs for real in Windows CI, where PowerShell is what answers -
the only place this path can be exercised without a 2 GiB fixture - and
elsewhere it says it did not run rather than pretending.

README and the help said native Windows was not even a viewer past 2 GiB,
which was true for one commit. It is a viewer again, and they now say so,
along with what it costs (process startup a page) and that writing is still
refused.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
E363 on the first real page: "Pattern uses more memory than
'maxmempattern'", from the hex check I added one commit ago as cheap
insurance. '^\%(\x\x\)*$' is a quantified group over the entire run, so on
a 128 KiB page - 262144 characters - the engine runs out of the memory
'maxmempattern' allows it.

`\X` (one non-hex-digit atom) plus an even-length test asks the same
question in a single linear scan: measured at 7 ms on that page, against an
exception. It still catches what it was there for - a BOM, a stray warning,
a partial write - which the test checks rather than assumes.

This is the third member of a family this codebase already knows: the \zs
that consumes its newline, and the negated collection that matches the
end-of-line. All three look right, all three pass on short input, and all
three only fail at page size. The rule they share is now written down where
the other two are: a regex over a whole page must be TESTED on a whole
page. The new test is that - the check itself, run on 262144 characters,
accepting real hex and rejecting rubbish - and it fails on the old pattern,
which I confirmed by putting it back.

My own fault, and the annoying kind: the check exists to stop something
plausible-looking reaching the dump, and it was itself the thing that broke
the page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A page turn past 2 GiB on Windows took about thirty seconds, with Vim
looking hung. Two causes, and the big one was mine.

The dump was being built byte by byte in VimScript, on the reasoning that
xxd could not be asked for those offsets. That reasoning was half right:
xxd's limit is its SEEK, not its formatting. Given a temp file that starts
at offset 0 it has nothing to seek and is exactly as good as ever. So
PowerShell now does only the thing that actually needed it - the 64-bit
seek - and writes the page's raw bytes to a temp file; xxd dumps that.
Measured on a 128 KiB page: 3530 ms of VimScript against 11 ms of xxd.

The one thing xxd cannot be told is the offset column, since -o holds its
display offset in an unsigned long as well and would wrap at 4 GiB. That is
now the only part done here: HexPairPagedRebaseDump() renumbers 8192 lines
in about 25 ms. Its test compares the result against what xxd itself prints
with -o for the same bytes at the same base - trustworthy on a 64-bit xxd,
and precisely what Windows cannot produce, which makes it the right
reference rather than a restatement of the format.

The second cause: a page load read the same range TWICE, once for the dump
and once for b:hexpair_page_hex, because those are separate call sites that
did not know about each other. On the fast path that is two cheap xxd runs;
on a path whose entire cost is starting a process, it was half the total.
The slow path now reads once and derives both.

Also stops PowerShell converting the bytes to hex before handing them over,
which doubled the data and cost more than the read. It writes bytes;
xxd -p makes hex of them when hex is what is wanted.

48 ms of work per page turn now, plus one process start.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Docs only; no behaviour change, and the version number is left alone.

CLAUDE.md's row for the Windows 2 GiB bug had been appended to three times
across as many commits and become one run-on that contradicted itself: it
still named HexPairPagedDumpLines(), which no longer exists, and still gave
the guard's old name. Split into two coherent rows - one for the bug and
the two call sites it needed fixing at, one for why readblob() looks like
the answer and is not.

Two more guard names in that table did not resolve to any test. One was
mine, renamed when readblob was dropped. The other predates me and is more
interesting: "the text view is left unmarked, not marked in the wrong
columns" guarded behaviour that a later commit deliberately REVERSED -
b7ea116 draws the markings in the text view properly instead of clearing
them - so the row was guarding the opposite of what the code now does. It
names the current guards and says it was superseded.

Every guard name in the table now resolves to a real test; that is worth
checking mechanically each audit, since a renamed test leaves the row
pointing at nothing and nothing else notices.

The Vim 8.0 floor was re-run: 475 ok, 38 failures, no E... anywhere. The
count moved from 33 because the five :saveas/:file tests are save-as and so
refused by the same readblob() gate as the rest of that set - the names
match, which is what CLAUDE.md says to read. Everything else added since
the last audit passes on 8.0.

Checked and already right: commands, <Plug> targets and the mapping lists
agree across the plugin, hexpair.vimrc, README.md and the help (32 targets,
33 commands, all three lists identical); helptags builds with no duplicate
tags; both generators reproduce their committed output byte for byte; the
tarball is byte-identical across two builds at 19 files; no dead functions
(the eight that look unreferenced are reached through <SID> or, for
s:ByStart, as a sort() comparator name); shell and Python files pass -n.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Asked whether PowerShell could write as well as read. It can: .NET's
FileStream.Seek takes an Int64 in both directions, so the tool that fetches
the bytes can put them back. No other component is a candidate - certutil
cannot seek, fsutil is not general and wants admin, and the Windows Script
Host is what was already rejected for antivirus reasons - so it is
PowerShell or nothing, and it ships with every Windows since 7.

Only the SAME-LENGTH overwrite uses it, which is the ordinary hex edit:
type over some bytes, :w. It touches one range and changes no lengths, so
one seek and one write is the whole operation. Length-changing writes stay
refused: they move the tail or splice the file, several operations each
placing bytes on the strength of offsets that cannot be tested from here -
and s:CopyRange(), which the splice reads through, uses readblob(), which
is itself broken on Windows for large files. Refusing costs an edit;
getting it wrong costs the file.

':w {file}' and ':saveas' past the limit are refused for that same reason,
and now explicitly: removing the blanket refusal uncovered them, since they
walk the whole file through s:CopyRange().

The write verifies itself by reading back what it wrote. That is not belt
and braces - this path cannot be exercised where it is developed, and the
failure it guards against is a page written at the wrong offset in a file
too large to notice it in. Verifying turns silent corruption into a message.
It costs one more process start on a path that already pays one, and
g:hexpair_verify_writes = 0 turns it off.

Tested at small offsets, which runs for real in Windows CI: the bytes land
where they were told, and - the half that matters - the bytes on either
side are untouched, which is what a wrong offset would have eaten.

Both PowerShell calls now trace through g:hexpair_debug, so how many
processes a given operation starts is visible rather than guessed at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Windows job times out at fifteen minutes inside this block, which is
the first place in the suite that starts PowerShell at all - past 2 GiB a
Windows Vim reaches a file that way rather than through xxd.

Skipping it there is defensible on its own terms, independently of the
hang: the block exists to test xxd's offset column widening from eight
digits to nine at 4 GiB, and on Windows past 2 GiB xxd is not what reads
the page, so it stops testing that and starts exercising a different
mechanism. Nothing is lost - the widening is checked without a 4 GiB file
by "and the column widens past eight digits", and the PowerShell read and
write paths by the checks that call them directly.

But it is NOT a fix, and CLAUDE.md says so rather than letting a green CI
imply otherwise. The cause is unknown. The same reads and writes work on a
real Windows machine on a 120 GiB file at about a second a page, so
whatever blocks is about that runner - PowerShell absent, shimmed or
policy-blocked - rather than about the code path. Timing
`powershell -NoProfile -NonInteractive -Command "exit 0"` in that job is
the one measurement that would settle it, and it is written down as the
next step.

$IS_WIN is asked of the Vim under test, not of the shell: this suite runs
under Git Bash on Windows, so uname says MSYS whichever Vim is being
tested, and what decides the code path is the Vim's own platform.

Verified the skip by forcing the branch: 509 checks instead of 516, the
seven this block owns and nothing else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t itself

Two things.

The CI failure first, and it was the test's fault rather than the code's.
"renumbered offsets are what xxd itself would have printed" compared the
renumbering against `xxd -o` - but displayoff is an unsigned long in xxd as
well as the seek, so on Windows the REFERENCE is the wrapped one. The test
was checking the bug against itself and rightly disagreed. That comparison
now runs only where -o can be believed; the literal assertions beside it
carry the case on Windows and need no reference at all.

Then the rest of the write path. `xxd -r` shares the 32-bit offsets, so
every step that moves bytes is PowerShell's now:

- s:SeekSetLength() resizes, and s:SeekMoveRange() slides a range within
  the file. It is FULLY BUFFERED - the whole range is read before any of it
  is written - because a tail sliding by less than a block overlaps itself,
  and that is the case a streaming copy eats.
- s:GrowInPlace() needed no change at all, only its two primitives:
  extend, move the tail backwards from its end, patch.
- s:ShrinkInPlace() is new, and has no counterpart on any other platform.
  Neither Vim nor xxd can shorten a file except by writing it afresh, which
  is why a shrinking write is a whole-file splice everywhere else. .NET's
  SetLength truncates, so here the expensive case is the cheap one: a
  120 GiB file shrinks by moving the bytes after the edited page.

The ORDER in both is the correctness argument and is written down where it
is: grow extends before it moves anything, shrink cuts the file LAST, so a
failure part way leaves a file longer than intended and never shorter - the
bytes are all still there to try again with.

Every write reads back what it wrote; g:hexpair_verify_writes opts out.
This path cannot be exercised where it is developed, and the failure it
guards against is bytes placed in the wrong gigabyte.

`:w {file}` and `:saveas` past the limit stay refused: they copy the whole
file through s:CopyRange(), which reads with readblob().

The new checks run for real in Windows CI at small offsets - the same
functions a resize of a huge file calls. The move is checked in both
directions with source and destination overlapping, against expectations
worked out by hand rather than read back from the run, and I confirmed both
can fail by feeding them wrong ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The last write still refused there. ':w {file}' and ':saveas' build a saved
file out of three copies - the head before this page, the page, the tail
after it - and s:CopyRange() reads those through readblob(), which is what
Windows breaks. s:SeekCopyRange() does them instead.

ONE process for a whole range, with the chunking inside the script rather
than in the Vim loop. s:CopyRange()'s loop lives in Vim because readblob()
has to be called per block; this is the operation that walks an entire
file, so a process per block would be thousands of them.

That also makes the splice work again, which changes what the comment above
the resize branch could honestly say: it claimed there was no splice to
fall back on. There is now - it is just the wrong choice, and for a reason
worth writing down properly, so the docs now state it as the inversion it
is. Past 2 GiB on Windows the operation this plugin calls most expensive is
the cheapest one available: neither Vim nor xxd can shorten a file except
by rewriting it, which is why a shrinking write copies everything
everywhere else, and .NET's SetLength truncates in place. So both
directions stay in place here whatever s:TailShiftIsCheaper() would have
picked, and a 120 GiB file shrinks by moving the bytes after the edited
page.

Answers the other question asked, in the help and in CLAUDE.md, because it
is exactly the kind of thing someone will later think is an oversight:
PowerShell is deliberately NOT used on Linux, macOS or the BSDs. pwsh runs
there and the same FileStream calls would behave identically - but a `long`
is 64 bits on those platforms, so xxd already seeks correctly and is faster
than starting a process. A runtime-detected pwsh path would buy no
correctness and cost a process start per read. This fallback is for where
xxd is broken, not for where another tool happens to exist.

CHANGELOG pruned while here, on the reminder that it records the release
and not the road to it: two entries described bugs I introduced and fixed
inside this same unreleased cycle - the VimScript formatter that made a
page turn take 30 seconds, and the E363 hex check - and neither was ever in
a release. The 2 GiB story is now one entry saying what changed against
v2.2.0: past that limit Windows read and wrote the wrong offset, and now
does not.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Asked what happens at the boundary - part of an operation below 2 GiB and
part above - and the answer was not uniform. Every guard tested the END of
the range it was about to touch except s:FileHex(), which tested the start.
A read beginning below the limit and crossing it therefore went to xxd: it
would very likely have worked, since the seek is what is 32-bit and fread
merely advances, but "very likely" is not a thing to decide on a platform
this cannot be tested on. All the guards now ask one question - can xxd
reach the WHOLE range - and a test pins it, including the straddle, with
different expectations per platform rather than a skip.

s:ShrinkInPlace() also called s:SeekWriteRaw() directly, which forced
PowerShell for a page below the limit inside a file above it. It goes
through s:PatchInPlace() now, which picks by offset, so only the parts that
have to be PowerShell's are.

Then the documentation that was asked for and genuinely missing: a table,
in README.md and in the help, of which mechanism each operation uses on
each side of the line, and what it costs. The costs are the interesting
part and now say so plainly - below 2 GiB nothing changes anywhere; past
it, one process start per operation, which a page turn absorbs and a
file-wide scan does not; and the shrink row inverts the plugin's own cost
model, because SetLength truncates in place and neither Vim nor xxd can
shorten a file at all.

Two claims left over from when this was less finished: README still opened
the section with "files over 2 GiB cannot be read or written on native
Windows", and the summary at the top still said writing was refused.
Neither had been true for several commits. That is the failure mode of
documenting a moving target, and worth a sweep each time rather than a
re-read.

CHANGELOG gains the capability as an Added entry - the Fixed entry covers
the wrong-offset bug, which is a different statement from what a user can
now do - and says where the table is.

Vim 8.0 floor re-run: 489 ok, 38 failures, the same names as before, no
E... anywhere. Everything added since the last audit passes there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Asked what happens when a 5 GiB file reaches hex mode by the other two
routes - opened normally and toggled, or piped in. Both turned out to be
structurally fine: s:PageSource() points b:hexpair_page_file at the real
file (or at the spill it writes piped input to, sequentially, with no
offsets involved) and everything after that is s:LoadPage() and the guarded
reads. Nothing unguarded on either path.

But the question turned up something else. getfsize() has two answers that
are not sizes: -1 when it cannot see the file, and -2 when the size does
not fit in a Number - which on a build without +num64 is EVERY file over
2 GiB, and +num64 is not required for paging, only for the splice. This
plugin's whole subject is files that size.

s:LoadPage() tested `<= 0` and read both as "no bytes". So on such a Vim a
5 GiB file opened as an empty view, with the page count, the bounds and
every offset derived from it meaningless, and nothing anywhere saying so.
It is the quiet kind of wrong: the view looks like a legitimate answer to a
legitimate question.

s:FileSize() throws for both, naming the cause - "needs +num64 for files
over 2 GiB, :version says whether this one has it" is actionable where "no
pages" is not. Zero stays a real answer, because an empty file is openable
and says so, and there is a test for exactly that so the fix cannot
swallow the case it sits next to.

The -1 half is tested by asking for a file that is not there. The -2 half
needs a Vim without +num64 and a file over 2 GiB at once, so it is checked
by reading rather than by running, and CLAUDE.md records it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
claude and others added 18 commits August 31, 2026 09:03
Three things, all found by asking what a hostile configuration does.

g:hexpair_bytes_per_line = 0 was ACCEPTED. The only check was "page size
must be a multiple of it", and Vim answers 512 % 0 with 0 rather than an
error - so a zero width passed the multiple test and failed later and
obscurely, in xxd -c 0 and in every column sum on the page. A negative one
passed the same way. Width is now checked by name, and first.

g:hexpair_page_size had no upper bound. It is capped at 2147483647, and the
number is a correctness boundary rather than a tidy one: a page's length is
handed to xxd's -l, a C long and so 32-bit on Windows, and to PowerShell as
an [int], 32-bit everywhere. A larger page would overflow both SILENTLY
rather than fail - the failure mode this area has already produced once.
Long before the cap it is impractical anyway: a page is held as hex at two
characters a byte and drawn at nearly four, so 2 GiB of page wants some
8 GiB of Vim. The cap is where correctness ends, and the message says so.
A page just under it is still accepted, so it is a boundary and not a mood.

And the renumbering of the offset column existed twice: once as
s:CanonicalDump()'s fallback for an xxd without -o, once as the Windows
path past 2 GiB. Identical arithmetic in two places is one more than the
number of places a format can be got right in, so there is one
HexPairPagedRebaseDump() now and the fallback calls it.

Which exposed a gap in its tests: they all used the default 16 bytes per
line, so a renumberer that advanced by 16 rather than by n would have
passed every one. They now run at 8, 23 and 32 as well, against what xxd
itself prints with -o, and I confirmed they fail if n is hardcoded.

Answers the question behind all of this: a non-default width needs nothing
special on the PowerShell path. xxd is told -c N there as anywhere else -
PowerShell only fetches bytes - and the renumbering advances by N.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The message said "must be a positive number of bytes per dump line", which
answers half the question a reader has: positive, yes, but does it have to
divide something? Be a power of two? Match the terminal?

It does not. The width divides nothing - an odd 23 is as good as 16 - but
there IS an upper bound, and it was not being enforced: xxd's own
`#define COLS 256`, above which it exits with "invalid number of columns".
A larger setting therefore reached the user as a command that failed, with
nothing in the message connecting it to the option that caused it.

So the check is now the range 1 to 256, and the message says the range, says
the width need not divide anything, and says the one thing that does have to
divide - the page size, by it. Asked whether something like 1024 would be a
sensible cap: the real ceiling turns out to be lower and is not ours to
pick, which is better than a number chosen for feeling about right.

256 exactly is accepted and 257 refused, so the boundary is tested from
both sides rather than asserted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found auditing for release, and all four predate this branch. :help follows
a |ref| to a tag, and these name tags that do not exist:

  |HexPairFind|          the highlight group's tag is *HexPairFind-highlight*
  |HexPairMark|          likewise *HexPairMark-highlight*
  |:HexPairPageNext!|    the tag carries no bang
  |hexpair-paged-size|   no such tag; g:hexpair_page_size is what is meant

A reference that resolves to nothing is not visible from reading the file -
it looks exactly like one that works - so it is now checked mechanically:
every |ref| in the help is either a tag defined there, or one from Vim's
own documentation, and the ones that are neither are these four. Worth
re-running whenever a tag is renamed, which is how three of them broke.

The Vim 8.0 floor is re-run and unchanged in kind: 501 ok, 38 failures, the
same names as before, no E... anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d in

The 4 GiB straddle block is skipped on Windows because the job hangs in it
and times out at fifteen minutes. Skipping shows the rest of the suite and
explains nothing, so this measures the thing that would.

Three measurements, and the third is the point. The two bare runs say
whether PowerShell starts on that runner at all and what a cold start
costs. The third does exactly what s:HasPowerShell() does - a native
Windows Vim, system(), the same arguments - because that is the failing
path, and a bare run that succeeds says nothing about it. It also prints
&shell, which is the first thing to suspect if system() is what blocks.

Diagnostic rather than gating: continue-on-error, so a runner without
PowerShell reports instead of blocking a release, and a three-minute
timeout of its own, because "it hangs" is one of the answers being looked
for and finding it out should not cost the fifteen the suite costs.

CLAUDE.md says how to read the three lines, since the useful outcome is the
one that looks like a contradiction: bare runs quick, the Vim one silent -
which would mean the problem is that call and not PowerShell.

The probe's Vim half is checked here by running it with the PowerShell
command swapped for a no-op, so what reaches CI is known to produce output
rather than assumed to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cript

Three things asked for.

The resize prompt was lying on Windows. s:ConfirmResize() decided "in place
or whole-file rewrite" with its own copy of the rule, and that copy did not
know that past 2 GiB both directions go in place - so it announced a
whole-file rewrite while shortening a 120 GiB file by moving its tail.
Alarming, and false, and said before a destructive operation. There is one
s:ResizeIsInPlace() now, which s:Write() acts on and s:ConfirmResize()
describes, so the message and the plan cannot differ again.

'$-N' jumps, for pages and for bytes: '$-5' is five back from the end,
which a bare '-5' cannot say - that counts from wherever you are. The byte
form takes hex too ('$-0x10'). '$+N' is refused where it is typed rather
than left to the bounds check, since it names a page or byte past the last
one, which is never one - asked for, and right: the user knows which end
they meant, so the message should be about that and not "does not exist".

One trap worth recording: '$' now parses to {'last': 1, 'delta': 0}, and
s:GotoOffset() tested for 'delta' BEFORE 'last' - so '$' silently became "a
step of zero from the cursor" and stopped working. The order is the
correctness argument there, and it is commented as such.

The wrappers needed no code at all: vimhex and vimhex.cmd pass the value
through the ENVIRONMENT into $NAME inside a Vim expression, which is
exactly what lets a '$' survive - verified by running the Ex invocation
they build. They and the README, the help and both prompts document it.

And test/check-large-file.sh, which the suite cannot be: it builds a
multi-gigabyte file whose byte at any offset is a function of that offset,
edits it past 2 GiB, and after each edit checks three separate things - the
bytes that should have changed did, the bytes on either side did NOT, and
the length is what the edit implied. A whole-file hash answers none of
those separately and costs more than the test. It refuses under 3 GiB
rather than run every check against the ordinary paths and report a success
that means nothing.

Run here on a real 3 GiB file: all twenty checks pass. Its own arithmetic
cost one of those runs to get right - the edits rewrite the first columns
of a dump LINE, so the offset must be line-aligned or every expectation is
off by up to fifteen bytes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mand

The Windows paths past 2 GiB had never been exercised anywhere but by hand.
They are now: test/check-large-file.cmd runs twenty checks against a 3 GiB
fixture on a native Windows Vim and passes all of them - the same-length
patch, the grow with its tail slide, the shrink SetLength makes cheap, and
':w {file}' - each verified byte for byte against a pattern predicted from
the offset, so a read or a write landing anywhere else shows up as a
mismatch rather than as a plausible-looking dump.

That matters more than a test usually does, because this is the path that
was silently returning the bytes at 2 GiB for every offset past it, and
writing them back there too.

It needs nothing installed. cmd's `set /a` is 32-bit and the offsets here
are around 2.7 billion, and python3 is not a thing to require on a Windows
machine - while PowerShell is already what hexpair uses past 2 GiB, so the
check needs nothing the feature under test does not. The .cmd is the driver
and runs Vim; the .ps1 computes, builds the fixture and checks bytes, in
phases, leaving an edit.vim behind between them. That division is not
decoration: every arrangement in which PowerShell started Vim hung on the
maintainer's machine, while a batch running `vim -es -u NONE -S file <nul`
never did. Five explanations for that were argued and all five were wrong,
and the hang later stopped happening on its own - so what is kept is the
shape that never failed, not a theory about why.

test/check-large-file.sh keeps the same twenty checks under the same names
for Linux, WSL and the Vim Git Bash launches, and both now preflight the
read on a 256-byte file before committing three gigabytes of disk to a run
that cannot work.

One real plugin bug came out of it. `s:xxd` was assigned by the two entry
points alone, on an assumption written down in s:HexOfString(), which
carried its own copy of the workaround: that everything runs with a page
open. Anything arriving another way got E121: Undefined variable instead of
a message about xxd. There is one s:Xxd() now, resolving on demand and
throwing a message rather than interpolating an empty command into a
printf. Guarded by "the reader resolves xxd itself, with no page ever
opened", which runs in a Vim that has opened nothing.

Also here: *.ps1 is pinned to CRLF, because these scripts GENERATE batch
files and a batch file with LF endings does not run under cmd.exe - a rule
this repository already had for *.cmd and had applied to the wrong half of
the problem. And PowerShell is invoked with -InputFormat None, which guards
a known hazard and is documented as guarding one rather than as fixing
anything observed.

CLAUDE.md records what this cost and the rules it left, the first of which
is the one worth keeping: the instrument is part of the measurement, and
when a diagnostic and the thing being diagnosed disagree, take the
diagnostic apart first. The probe scripts that established all this are on
the agent-windows-hunt branch rather than here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
check-large-file.sh refused to start on WSL, and it was right to: its
preflight asked for HexPairPagedSeekReadHexForTest, which is the PowerShell
fallback. That exists because xxd cannot seek past 2 GiB on Windows. There
is no PowerShell on Linux and no need for one, so the plugin declined -
correctly, and with a message about a limit that platform does not have.

The preflight was copied across from the Windows checker, where asking for
that reader is exactly right, without noticing that the two scripts read
through different helpers. It asks for HexPairPagedFileHexForTest here, the
xxd reader every check below will use.

The twenty checks are unchanged and still identical between the two; what
each preflights is the reader it will actually depend on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e refused

Two documentation claims were stale, and one of them is in the help file
where users read it. |hexpair-windows-2gib| opened with "files over 2 GiB
can be READ but not written" and then, twenty lines later, described how
writing past the limit works - the sentence predates the PowerShell write
path and was never revised. README.md carried the same thing as "treat
hexpair as a full hex editor up to 2 GiB, and as a read-only viewer past
it". Both now say what is true: everything works past 2 GiB on Windows, and
what changes is how the bytes are moved and what that costs.

The README's Windows note is also toned down and given the sentence it was
missing. It read as a caveat about hexpair; it is a limitation of xxd on
one platform. It now says so, and states the ceiling everywhere else -
2**63-1 bytes, eight exbibytes - so nobody is left wondering whether their
own platform has a limit worth planning around.

CI runs both checkers now, one per platform, each as its own step with its
own budget. They are the only checks in this project that touch a real
multi-gigabyte file, and until now the past-2 GiB paths had no automated
coverage at all - which is what let a wrong-offset bug ship. Job budgets go
up to match; the Linux step puts TMPDIR on $RUNNER_TEMP because peak disk
there is three times the fixture (the file, the whole-file temp the shrink
splices through, and the ':w {file}' copy), against twice on Windows, where
SetLength truncates in place.

check-large-file.cmd now normalises HEXPAIR_VIM's separators, since CI
hands it a path spelled with forward slashes and cmd wants backslashes for
a program path.

The 4 GiB straddle block stays skipped on Windows, and its comment now
leads with the reason that is actually load-bearing: past 2 GiB there the
file is reached through PowerShell, so the block stops testing xxd's own
offset-column widening, which is the only thing it exists for. It would be
skipped there even if it passed. The CI hang that used to be given as a
second reason was never explained and stopped happening on its own, which
the comment now says rather than implying an open investigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The feature list pointed at #paged-large-file-mode, which is not a heading
in this document - the section it means has been called Pages for some
time. And it said only deleting bytes rewrites the file, which stopped
being true when SetLength made a shrink an in-place operation past 2 GiB on
Windows.

Found by checking every internal link against the headings the document
actually has, which is worth doing before a release and is now done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
:HexPairInspect could tell you a code point and not what it was. U+001B is
not an answer if you do not already know it is ESC, and U+4E2D is not one
unless you recognise the block on sight. Three rows now close that:

  name     ESC (escape), a C0 control
  block    Basic Latin
  bom      utf-16le byte order mark

They describe the UTF-8 reading, and only that one: it is the reading with
no byte order to choose, and naming all four would be four lines of mostly
noise. `bom` is the one deliberate exception, because ff fe is not UTF-8 at
all and "is this a byte order mark or data" is exactly the question someone
opening a file at offset 0 has.

WHAT IS NOT HERE, and why. There are no full character names - the LATIN
SMALL LETTER E WITH ACUTE kind. That is UnicodeData.txt: two megabytes of
names for a hundred and fifty thousand characters, a different order of
thing from a table of ranges, and not something to carry inside a Vim
plugin. Naming the block and stopping is the honest edge of what fits.

Two tables, sourced differently on purpose. The controls and format
characters are written out by hand - C0, C1, DEL, NBSP, SOFT HYPHEN, the
zero-width joiners, the bidi marks - because that is a small closed set and
exactly the one someone reaches for the inspector about. The 338 Unicode
blocks are generated by make-unicode-blocks.py from Blocks.txt, pinned by
version in its URL and by SHA-256 in the script, so a re-run either
produces the same table or refuses. Verified idempotent, from a local file
and over the network.

The block list is built on FIRST USE rather than at load: a list literal
inside a function body is stored as text until the function runs, so a Vim
that never inspects a character pays nothing for 14 KB of ranges.

One refactor came with it. HexPairPagedUtf8Text() decoded and worded in one
pass, and the inspector needs the code point rather than a sentence about
it, so the decoding moved into HexPairPagedUtf8Decode() and the wording
stayed where it was - rather than the sentence being parsed back, or the
decoder written twice and drifting.

Eighteen checks, on the wording and on the block boundaries either side -
a lookup off by one lands in the neighbouring script and reads perfectly
plausible, which is the way this could go wrong without anyone noticing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
<Leader>i answers the same questions in plain text that it answers in a hex
view - what is this character, is that a NBSP, does this file start with a
BOM - and it refused to, for no better reason than a guard at the top of
s:Inspect(). Nearly everything it needs was already there: the text view's
byte reader works on any buffer once it is not told to skip a banner, which
is a two-line change.

What needed care is what it SAYS, because this is a hex editor and showing
bytes that are not in the file would be worse than showing none. A paged
view holds the FILE's bytes, since hexpair opened it ++bin. An ordinary
buffer holds VIM's: a file read as latin1 into a UTF-8 Vim is latin1 on
disk and UTF-8 in memory, and a 'fileformat' of dos has CRLF on disk and no
CR at all in the buffer. A note row says which is on screen, and appears
only when the two can differ:

  note     Vim's bytes, not the file's: 'fileencoding' is latin1, this Vim holds utf-8
  note     a line break reads 0a here; 'fileformat' is dos, so the file has 0d 0a

Nothing is marked on screen there. The marking is drawn by the highlighting
a paged buffer has and an ordinary one does not, so the bang says it had
nothing to take off rather than quietly doing nothing.

And the "ran out of bytes" line now takes its phrase from the caller, since
"only 6 bytes left on this page" is simply untrue in a buffer that has no
pages.

Seven checks, including the two notes and the empty buffer, which says so
rather than throwing on a line2byte() of nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two things the maintainer was right to ask about.

THE LICENCE. Blocks.txt is under the Unicode License V3 (SPDX:
Unicode-3.0), which is permissive - it allows using, modifying and
distributing the Data Files - on one condition: its copyright and
permission notice must appear WITH the copies or IN the associated
documentation. Deriving a table of ranges from it and shipping that table
without the notice would not have met it.

NOTICE.md now carries the notice verbatim, with the source URL, the SHA-256
the generator checks against, and the SPDX identifier, and states plainly
that the plugin itself remains under the Vim License. It is in
pack-release.py's FILES, so it ships in every tarball - and the packaging
test keeps it there without anyone remembering to, since every .md outside
test/ must be listed. The table's own header in plugin/hexpair.vim, the
generator, README.md, the help file and CONTRIBUTING.md all point at it.

The trademark line is there too: naming Unicode as the source of the data
is provenance, not endorsement, and the licence forbids using the name to
promote the software.

CLAUDE.md records the rule rather than this one instance, because the next
piece of third-party data will arrive the same way: check the licence
before deriving, record the provenance, ship the notice.

WHEN THE GENERATOR RUNS, which my own wording obscured. "Built on first
use" was about Vim storing a function body as text until it is called - the
list literal costs nothing at load - and it read as though something were
generated at run time. Nothing is. make-unicode-blocks.py is a development
tool, run by hand when the Unicode version is bumped; its output is
committed. Nothing generates anything at package time, and nothing runs on
a user's machine. Said outright now in the plugin, the generator, CLAUDE.md
and the README, with "evaluated" where "built" was doing the damage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
:HexPairInspect put a block row next to a byte order mark that read
"Arabic Presentation Forms-B", and a hex editor reaching for the answer
to "is this byte Arabic?" deserves an honest one.

The block itself is real - U+FEFF really is assigned to that block in
Blocks.txt. The trap is why: Unicode lays out blocks by CONTIGUITY, not
by script, and FE70..FEFF is the range that happens to close the BMP,
so U+FEFF lands in it as the last code point of that range. A byte order
mark is a format character (category Cf) and belongs to no script, so
the block row asserted a script where there is none. The name row (BOM,
zero width no-break space) and the bom row (utf-8 byte order mark)
already say exactly what the byte is; the block was pure noise with a
script's name on it.

The fix is in HexPairPagedCharNotes(), where the name and block rows are
built: a byte order mark is named and shown as a BOM and shown no block.
No other code point is touched, and none needs it - the other format
characters the inspector names (NBSP, SOFT HYPHEN, the zero-width and
bidi marks) sit in General Punctuation or in a block whose name is their
home, and ALM, the only other one in a script-named block, really is an
Arabic mark. So the block table is left faithful to its source and only
the one row that would mislead is withheld.

Two new checks: a named code point gets a name and a block (all the
usual suspects), and the byte order mark is the one named without a
block, on the full report of a BOM at the cursor. The README, the help
file, the changelog and CLAUDE.md all record the exception, so that the
next reader of the block table does not take the missing row for a bug
and put it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: e-INFRA CZ AIaaS <noreply@cerit-sc.cz>
A file opened normally and switched to hex with :HexPairToggle has, until
now, had no way back but closing and re-opening it. :HexPairUnhex gives
toggled buffers their ordinary, unpaged, non-binary view again: it
re-opens the whole file with the 'binary', 'fileencoding', 'fileformat'
and the other buffer options it had before the first toggle, with the
cursor where it was.

The mechanism is a snapshot. s:ToHex() captures those options the first
time a buffer toggles from plain (b:hexpair_plain) and only then, because
s:PageSource() re-reads the buffer ++bin and takes the options over, and
none of it is undoable from the buffer afterwards. s:UnhexPlain() hands
the values back rather than guessing at the defaults: a plain :edit keeps
every local option the paged view left there, so the read-affecting ones
go back before the re-read, the xxd ftplugin's undo runs while the
filetype still says xxd, and the rest after. The buffer is re-read from
disk, not converted, because a page is not the file.

The one door it refuses on purpose. A buffer opened as hex (:HexPairOpen,
vimhex) has no plain view to return to - it may never have been read as
text, and its file may be far too large to load for the purpose - so
s:Open() never takes the snapshot, which is the whole test. A buffer with
no file of its own (piped in) is refused too, as is one whose file has
changed on disk since the page was read; a buffer that has never been in
hex mode is told that, rather than being told about entry points it did
not use. And because a plain :edit would drop a page's unwritten edits
without a word, the command refuses them unless given ! - :HexPairUnhex!
discards them and re-opens, and the file's bytes on disk are never
touched by the re-read.

Four things about the way back are less obvious than they look, and each
of them is a check in the suite:

- 'paste' is GLOBAL and is switched on while the cursor is in a hex
  buffer. The BufLeave that switches it back is one of the autocommands
  the unhex deletes, and this buffer is never left - so the unhex puts it
  back itself, or the file comes home with the user's insert-mode
  mappings, abbreviations and automatic formatting silently off. Before
  b:undo_ftplugin, since 'paste' off restores the options it overrode.
- Nothing of hexpair's stays on the buffer, named by PREFIX rather than
  one at a time: a forgotten diff target or a cached page would be read
  as this view's own state the next time hex mode is entered.
- The file re-opened is the one the buffer holds NOW, read live. :saveas
  moves a view to another file and does not tell the snapshot, and
  re-editing a remembered name does not re-read this buffer - it opens a
  second one, and leaves this one paged with its autocommands already
  deleted, a hex view whose :w has no BufWriteCmd left to run.
- 'readonly' is not handed back the same way as the rest. The :edit has
  just worked it out from the file, which is fresher than the snapshot,
  so only a 'readonly' the user set BEFORE the toggle is re-asserted and
  a file that has since become read-only is never presented as writable.

s:AbandonSetup() keeps the snapshot rather than dropping it, and so does
the catch around s:PageSource() once the ++bin re-read has happened: past
that point the snapshot is the only record of the plain view there is,
and throwing it away leaves the buffer stuck in binary with nothing to
restore - and the next toggle recording that binary state as though it
were the plain one. :HexPairUnhex is the way out of such a buffer, so it
works on one with a snapshot and no page at all. Before the re-read, and
for every refusal, the snapshot goes: PLAIN is still on screen there and
the next attempt takes a fresh one.

Mapped to <Leader>U in hexpair.vimrc, following the existing mapping
format, with a <Plug> target. Documented in the help file, the README
(the two-views section, the command table, the mappings example), the
changelog and CLAUDE.md, whose "no way back to Plain" non-goal now says
which of the two doors it still applies to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: e-INFRA CZ AIaaS <noreply@cerit-sc.cz>
The plugin says it works on Vim 8.0. It did not, and nothing noticed,
twice - because the only thing that would have noticed was a manual
ritual: a recipe in CLAUDE.md, a build by hand, and forty expected
failures to be compared against a list in prose. A version floor is only
as true as its last run.

What was actually broken there. The generated Unicode block table's
markers were `"\ ` comment continuations, sitting inside the continued
list literal because that is the only comment form that works in one -
and that form is patch 8.1.0369, so on every Vim 8.0 s:Blocks() failed to
parse at all (E15 on the opening bracket) and took the whole naming half
of :HexPairInspect down with it. The markers move OUT of the literal
instead of changing form inside it, since there is no form that works in
there; make-unicode-blocks.py generates the `let` between them now, and
re-running it against the pinned Blocks.txt reproduces plugin/hexpair.vim
byte for byte, which is the only thing that makes a change to a generated
region checkable. A static check comes with it, because every Vim this
suite can be pointed at accepts `"\ ` and so no test written in Vim can
see the problem.

The same disease, worse, in the splice gate: it required patch 8.2.4906,
which is a patch about MS-Windows transparent backgrounds. What the
splice needs is readblob() with an OFFSET AND A SIZE, and those arrived
in 9.0.0795 - a major release later. Plain whole-file readblob() is
8.2.2343, which is presumably where the confusion started. So every Vim
between the two was told it could shorten a file, :w {file} or grow a
page with more than half the file behind it, and then hit E118: Too many
arguments for function: readblob inside s:CopyRange() - which, during the
copy BACK, leaves the target half written, the exact outcome the splice's
ordering argument exists to prevent. Checked against Vim's own
version9.txt rather than from memory, and the comment on
HexPairPagedGateMessage() records what it says.

So the ritual becomes a job. The suite asks the Vim it is pointed at what
it can do - HexPairPagedSpliceSupported() is global for that, since
asking the PLUGIN's predicate rather than restating a patch number keeps
the two from ever disagreeing - and check_splice() stands down at the
forty checks that need a splice, saying so and passing. Same names, same
count on every Vim, so the check-count tripwire still works. Skipping is
only half of it: a refusal nothing checks looks exactly like a write that
quietly did nothing, so four checks in their place ask for a shrinking
write and a :w {file} outright and require the refusal, the gate's own
message and a file with every byte it had. That is the first time the
gate's failure branch has been exercised end to end.

test-oldest-vim then shallow-clones the v8.0.0000 tag, checks the commit
id it got (pinned by COMMIT, not by the archive tarball's digest - GitHub
regenerates those and their hashes have moved under people before),
builds it in about a minute and runs the whole suite. It passes there in
full, so this is a green tick or a broken build. The recipe was run start
to finish before being written down, including `make -C src`, which is
not the top-level target that 8.0's Makefile does not have.

The documentation stops hedging. README said "Vim 8.0 with patch
8.0.0794", which has not been true since count() left the plugin; it now
says Vim 8.0, any patch level, and says who that is for - RHEL 8 and its
rebuilds ship vim 8.0.1763 as their current package and are supported
into 2029, which is the answer to whether the floor is worth its
constraints. It also distinguishes "the 8.0 series" from "8.0.0000":
`"\ ` needs 8.1.0369, so the block-table bug hit every real 8.0, and
testing 8.0.0000 is the strict superset that makes the claim one sentence
long. CLAUDE.md's baseline section loses its list of expected failures
along with the instruction to read the names rather than the number.
There is no list any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: e-INFRA CZ AIaaS <noreply@cerit-sc.cz>
:helptags checks that the tags a help file DEFINES are unique. It says
nothing about the ones it REFERENCES, so a |tag| that exists nowhere is a
dead end the reader only finds by pressing CTRL-] and being told there is
no help for it. Eight had accumulated:

  |b:undo_ftplugin|     Vim's tag is undo_ftplugin, without the b:
  |hlsearch|            the option's tag is 'hlsearch', with the quotes
  |hl-Comment|          Vim has no such tag; the standard groups are
                        documented under |highlight-groups|
  |nmap| |nnoremap|     the command tags carry the colon
  |packload|            the tag is |packload-two-steps|
  |:HPFind|             the short command names have no tags of their own
  |:HexPairInspect!|    a bang is not part of a tag name

The two that are not Vim's are now plain literals rather than links,
since neither names something that can be jumped to; the rest point at
the tag that exists.

And a check so this is the last time: every |reference| in the help must
resolve to a *tag* in the same file or to one in the tags of the Vim
under test - which is what that Vim would actually do when the reader
follows it, and is skipped where a runtime has no doc/tags built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CONTRIBUTING's release process says to do this before the version is
bumped, and lists what to look for. Going through it found four things.

**A changelog entry must describe the release, not the cycle.** Its own
rule: `Fixed` is what a *released* version did wrong, and a bug both
introduced and fixed inside one cycle names code no release ever shipped,
so it belongs in CLAUDE.md's bug table instead. The byte order mark
reading as "Arabic Presentation Forms-B" was exactly that - the `block`
row it was wrong about is itself new in this cycle - so it leaves `Fixed`
and the behaviour it describes joins the `Added` bullet that introduces
the row, where a reader meets it as what the feature does rather than as
the repair of something they never had. The splice's wrong patch number
stays in `Fixed`: that one shipped in v2.0.0.

Two sections were both called `Changed`, which Keep a Changelog does not
mean; they are one now. The Vim-requirement bullet leads with what a user
reads - Vim 8.0 at any patch level, 9.0.0795 for the three writes that
change a file's length - rather than with the CI job that proves it. And
the `:HexPairUnhex` bullet named one of its three refusals as though it
were the only one.

**Is every code path tested from every entry point it has?** :HexPairUnhex
is reachable from the windowed text view as well as the hex view, and the
two are not the same buffer state - a dump against raw bytes, and a banner
recognised by different means. Only one route was covered. The write path
had a bug of precisely that shape, so the other one is a check now.

**CONTRIBUTING had gone stale in three places**: plugin/hexpair.vim is
described as "the base plugin (whole-buffer toggle)", which it stopped
being when hex mode became always-paged and hexpair_paged.vim was folded
back into it; the large-file checks are called "run by hand" though CI has
run them per platform for some time; and the CI section described two jobs
where there are four. Its Testing section now also says the thing a
contributor has to know before writing a check - that the suite runs
against Vim 8.0.0000, and that a check needing a length-changing write
goes through check_splice().

Also one wording: the unhex refuses a buffer that never entered hex mode
with the sentence :HexPairRefresh already uses for the same situation,
rather than one three words longer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@michal-ruzicka
michal-ruzicka merged commit d9c0853 into main Sep 2, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants