A tiny, POSIX-style modal editor that any program can drive.
Every vi calls out to the shell. lvi also lets the shell call in: every view listens on a control socket, so any program can send it commands and read its state back.
It runs on just LuaJIT — no compiler, no build step, no C dependency — on Linux, macOS, BSD, and WSL.
- 📖 The manpage —
lvi.1is the complete command, ex, and configuration reference. - 🧰
contrib/is where search, syntax highlighting, quickfix, completion, diffing, multi-pane editing, folding, linting, formatting and many more live — the external tools that turn lvi into an IDE. - 🧭 Migrating from Vim translates the vim features and plugins you use daily into the lvi key, tool, or rc line that stands in for them.
- Scriptable from the outside. Tools can drive the editor, not just the other
way round.
echo-easy to automate, and it composes with pipes. - One command language, everywhere.
:excommands are what you type in the editor, and what a script sends over the socket, and what your config file contains — the same ex commands, no second "scripting language." (No Vimscript. The shell is the extension language.) - Full editor power over the wire. ex commands cover line work;
:normalsends literal keystrokes, so a tool can do anything you can at the keyboard —2dw,ci", a recorded macro — not just line edits. - UNIX as IDE. Search, syntax highlighting, and the bulk of the ex command
set come from composing programs you already have (grep, a highlighter, the
system
ex) over the socket — not from engines living inside the editor. - Small and legible. The whole editor is a dozen small Lua files, with everything unsafe or platform-specific quarantined.
- Modal editing you already know. Motions, operators, counts, registers,
insert mode,
.repeat, macros, undo/redo, and marks.
- A Vim/Neovim clone. No Vimscript, no plugin runtime, no in-process extension language. (Migrating from Vim maps what you use onto what replaces it.) The extensibility comes from external tools: the socket plus the shell; the editor process keeps state and logic, then farms functionality out via a few hooks and documented APIs.
- Not a reimplementation of ex. Unrecognized ex commands are delegated to the
system
exrather than growing lvi's own:s/:g/:m/… A realexships on every system lvi targets. - No native search or syntax engine. Both are external tools feeding the
highlight overlay; lvi ships no lexers, grammars, or search engine. (See
contrib/for the ready-made bolt-ons that provide them.) - No visual mode. The operator + motion + text-object model, the
!filter, and the line-oriented ex commands cover its uses and avoid complicating the editor; the manual's Without visual mode section is the translation table. - No splits or windows. One process edits one view; run several side by side
under whatever multiplexer you already use — tmux, screen, Zellij, or a tiling
window manager. The socket allows cross-view coordination via scripts
(see
contrib/lvi-diff). (Multiple buffers within a view are supported; on-screen splits are not.) - UNIX only. POSIX environment and standard CLI tools assumed; Windows outside WSL is out of scope.
lvi notes.txt # edit a file (h j k l, i/a/o, dd, yy, p, u, :w, :q …)Then, from another terminal, drive that same editor:
lvi -l # list running views: <wid> <socket>
lvi -w auto -- '%p' # print the whole buffer to stdout
lvi -w auto -- 'normal ggdG' # send normal-mode keystrokes (here: clear buffer)
lvi -w auto -- '120' # jump to line 120
lvi -w auto -- 'w' # savelvi -w is a normal Unix filter (data on stdout, errors on stderr,
meaningful exit codes) so it drops straight into a regular shell pipeline:
# Pull the live buffer through a formatter and diff it:
lvi -w auto -- '%p' | gofmt | diff - original.go
# Batch an edit across every open view:
for wid in $(lvi -l | cut -f1); do lvi -w "$wid" -- 'normal ggdd' 'w'; doneAny language can speak the protocol; lvi -w is just a convenience client
for locating the socket file.
Real modal editing — motions, operators with full composition (dw, cf.,
y`a, 2d3w), registers, . repeat, macros, multi-level undo/redo, marks,
folds (zf/zo/zc/zR, or :fold from a tool), and the
scrolling/positioning commands. An ex layer shared by the : prompt and
the socket, with anything lvi doesn't implement delegated to the system ex
(so :s, :g, :m, and the full address grammar just work). A config file
that's simply a list of those ex commands. A styled highlight overlay (:hl
:hi) and change hooks (:on change …) — the two primitives that let external tools paint syntax, search, and quickfix into the view. And a left margin of named columns (set gutter=number,gitchanges,lint) — line numbers, plus a column per tool to mark lines in, off by default.
Turn those on and you get syntax highlighting, live-buffer search, quickfix
lists, linting, formatting, toggleable spell check, fuzzy file-open,
go-to-definition from a language server — and even a two-way vimdiff with
scrollbind and a git add -p staging UI — all as contrib/ tools
and none of it compiled into the core.
👉 The manpage is the full command and configuration reference.
- LuaJIT (2.1) — the whole runtime.
- A POSIX terminal with
stty(used for raw mode).
No C toolchain, no luarocks, no external modules: argparse (CLI) and lust
(tests) are vendored. Ship the luajit binary plus the .lua files, or bundle a
single executable with luastatic.
Optional, only for the features that lean on them: a system ex (delegated
ex commands — present on every UNIX), scdoc (to build the manpage),
and a highlighter like Pygments or bat (contrib/lvi-highlight).
make man # render lvi.1 from lvi.1.scd (needs scdoc)
make test # run the test suite
make install # PREFIX=/usr/local by default; honors PREFIX and DESTDIRmake install puts the runtime tree under $(PREFIX)/lib/lvi, a launcher and
the contrib helpers on PATH, and the manpage under $(PREFIX)/share/man.
A few design decisions shape lvi. Each is small; together they're why it
needs no build step, no plugin runtime, and no embedded scripting language.
Everything unsafe or OS-specific lives in sys.lua and nowhere else: Unix
sockets, poll, terminal size, and the couple of structs that differ across
UNIXes. The hard part of binding libc from a scripting language is termios — a
large struct with divergent layouts per OS — so lvi never binds it: raw mode
shells out to stty, pure Lua, zero ABI. What remains needing the FFI is just
sockets + poll + a window-size ioctl: a tiny, stable surface, with the one
divergent struct (sockaddr_un) branching on ffi.os.
Implication: there is no build step and no C dependency, and because the
entire unsafe surface is one small, auditable file behind a plain interface, the
choice of LuaJIT stays reversible — swap sys.lua for a PUC-Lua + luaposix
version and nothing else notices.
set gutter=number,gitchanges,lint names the columns of the left margin, in
order. Two names are the editor's own — number and relativenumber, whose
content is a function of the row and the cursor. Every other name is a one-cell
column that a tool fills by pushing marks (:gutter gitchanges 4:+ 9:-),
replacing the column on every push exactly as :hl replaces a highlight group,
and colored out of the same :hi table.
The split is the point: placement is the config's, content is the tool's. There is deliberately no priority number, the thing that makes vim's sign column awkward — vim needs one because every producer shares one column, and here a linter and a git diff each get their own. Marking a line no longer means recoloring the text on it, so the marks and the syntax highlighting stop competing for the same cells.
Implication: the renderer's rows no longer start at screen column 1, and that
offset is confined to two functions (gutter.width, gutter.textw). Everything
measured in screen columns — wrapping, horizontal scroll, the paging commands —
measures against the text width instead. test/gutter_test.lua pins that down as
a property: a margin g columns wide must behave exactly like a terminal g
columns narrower.
ex.dispatch(command) → payload, status is the single core that executes ex
commands. The : prompt, the control socket, and the config file all call it —
the property that makes tmux so pleasant (the same tight vocabulary at the CLI,
in the config, and at the command prompt), and vi already had that vocabulary
in the form of ex.
Implication: lvi needs no embedded scripting language. The pressures that
bloated Vimscript don't apply: lvi is UNIX-only (it can assume sort, awk,
sed exist) and has no in-process plugin runtime. Extensibility comes from three
cheap things — any language can be a client, the ex vocabulary can grow, the
protocol stays stable — while state and logic live in external programs, across
a process boundary that keeps the core from drifting.
The dispatcher also has a fourth exit point: any command lvi doesn't recognize
is handed to the system ex. lvi writes the buffer to a temp file, drives
ex -s with a short script (a preamble that mirrors lvi's marks and cursor line,
then the command verbatim), and reads the result back as one undoable change. So
the whole of ex's line editing — :s, :g, :m, addressing — is available
Without lvi trying to faithfully reimplement the whole thing.
The normal-mode interpreter is a coroutine. Its getkey() doesn't read
input; it yields, and the main loop feeds it keys by appending to one queue
(ed.inject) and resuming. Writing the grammar this way means vi's stateful
commands read as ordinary straight-line code — 2d3w, f{char}, ci" — instead
of a hand-rolled state machine, and each motion composes with every operator for
free (a motion returns a target; an operator consumes the range to it).
Because one queue is the sole way keys enter the interpreter, four features collapse into "append keys to the queue":
.repeat — replay the last change's recorded keys.- Macros (
q/@) — record keys into a register, replay them. :normal— the socket's normal-mode escape hatch.- The live keyboard.
Each cost almost nothing to add because the plumbing was shared from the start. And because the coroutine parks between keystrokes, the editor keeps answering the socket even while you're halfway through a multi-key command.
Socket messages are line-tagged and self-delimiting (%begin / %data /
%end), modeled on tmux's control mode. Payloads are length-delimited, so
a buffer line that happens to read %end 1 ok can't corrupt the frame; dumping
raw buffer text out is always safe. One connection carries
many request/response exchanges, and a %event tag is reserved (not yet used)
for a future push/subscribe channel: the envelope is locked now so
notifications can be added later without breaking a single existing client.
The buffer is an array of immutable line-strings — the right fit for a
line-oriented editor, where "give me line N" is O(1). (So the largest
file you can open is bounded by available RAM, and opening is O(file size)
up front, not incremental.) LuaJIT interns strings, so immutability becomes
an asset: every mutation flows through one atomic primitive,
splice(), which records an inverse splice into an undo log. Undo is
therefore automatic and complete — no edit path can forget to be undoable —
multi-level, and cheap (an inverse stores only the changed lines, shared not
copied). Rendering is viewport-bounded: it only ever touches the lines and
columns on screen, so cost is independent of file size.
Behavior is covered by tests (test/, vendored lust): the buffer and its
undo log, the ex dispatcher, the wire protocol (including binary-safety and
chunk-boundary cases), the renderer (a pure function of editor state), and the
coroutine interpreter (driven by feeding it keys). The socket path is exercised
end-to-end by driving a headless editor over its socket.
See LICENSE.