Skip to content

Repository files navigation

vim

A composable vim engine in Rust. It is a library, not an editor: you bring a document and a place to draw, it owns the modes, the grammar, and the cursors.

Status: early. The contract crate is being frozen; nothing here is released.

Why another one

Most vim implementations are welded to one buffer type — a rope, a textarea, a specific editor's model. That makes them unusable anywhere else, and it makes vim-surround a special case inside the core instead of an ordinary extension.

This one separates three things that are usually tangled:

  • the grammar (count operator count motion) is a pure state machine over keys, with no idea what a document is;
  • the document is a trait the host implements, so a rope and a Notion-style block tree are both first-class, not one plus an adapter;
  • the vocabulary — motions, operators, text objects, actions — is a registry. Everything stock lives in vim-builtins and uses only the public API, so an extension is not a second-class citizen. vim-surround is the proof: it is written against the same API you get, and core has no surround branch anywhere.

Design decisions worth stating up front

These are the ones that usually get discovered too late.

The host owns the document. The engine reads it and proposes. A host implements Document, and that trait is the whole read surface: identity, document order, per-line text, byte length, metadata, caps(). Its invariants are stated on the trait (at least one line, ids stable across every edit that does not remove the line, no '\n' inside a line, len_of answerable without materialising the text). The engine never writes through it. It proposes an EditBatch, the host applies what it will and reports back as Deltas, and those deltas are the only thing that moves an Anchor. A native embedding hands over its real document and there is no second copy anywhere. A copy appears only where the engine sits behind a boundary that cannot answer a synchronous read, and it is then a non-authoritative mirror serving the same trait, with the delta stream as its one writer. Either way the rule is the same, and docs/CONTRACT.md states it as a guarantee: one writer, never the engine.

Positions are anchors, not offsets. A collaborator's edit arriving mid-command must move your cursor, not corrupt it. Deltas remap anchors; the confirmation protocol defines what happens when an edit the engine proposed races an edit the host already applied.

WASM is a target, not the interface. wit/vim.wit describes the interface, and it is load-bearing as a design forcing function: an interface that must survive being described in WIT cannot cheat with shared mutable state. But the component model has no native browser support today and jco calls its browser support experimental, so betting a keystroke path on it would be a bad trade. The plain Rust crate is the primary target and the only one that runs: no crate here builds a wasm artifact.

The WIT is a build input for exactly one thing. ts/ is a TypeScript package whose types are generated from it by @bytecodealliance/jco@1.31.0, pinned exactly. The command is jco types, the host mode: it emits the surface a TypeScript host gets from a transpiled component, which is the engine export as callable functions plus the shape of the host import it has to supply. (jco guest-types is the other mode and is the wrong one here, since it emits ambient modules for writing the guest in TypeScript, and the guest is Rust.) The generated tree is committed, and the ts-types job in CI regenerates it and fails if anything differs, so the TypeScript spelling of the interface cannot drift from the WIT. ts/src/index.ts is a thin shim over that: nominal brands for the handles WIT flattens to u64 and u32, two constructors that enforce the line-id minting rule, and the docs/CONTRACT.md invariants as doc comments. The same job runs tsc --noEmit, so the shim cannot outlive a type the WIT drops.

Be precise about what that buys: shape, in one direction. The generator makes the TypeScript match the WIT and nothing more. It does not check that the WIT and vim-types still mean the same thing, and neither one is generated from the other; keeping those two in step is still review, and a disagreement is a bug in whichever one moved (docs/CONTRACT.md). A browser module and the component itself are planned as separate targets behind the same description. Until one exists, read the world as a specification of a component nobody builds yet.

Extensions are compile-time crates, not loadable plugins. wac compose is a build step and browsers cannot dynamically link components. The extension API is the valuable part and it is real; the distribution story is "add a dependency". Saying so here so nobody designs a plugin registry around a capability that does not exist.

Dot-repeat, macros, and marks are in core from day one. They cannot be retrofitted: . forces every command to be a replayable key-intent and forces an action to be a pure function of its context and arguments. That constrains the extension API, so it is decided before there are extensions to break.

Document shape is declared, not guessed. Caps is four flags, and each one names what changes when it is false:

  • rectangular: Ctrl-V refuses with a bell and no blockwise state is entered. A rectangle needs a monospaced grid, and there is no column 7 that means the same thing in a heading and a code block. Half-working blockwise visual is worse than none.
  • split_lines: no Edit::SplitLine and no Edit::JoinWithNext are ever emitted; Enter in insert and J in normal bell instead.
  • reorder_lines: no Edit::MoveLine is ever emitted; ddp and a visual block move bell instead.
  • set_depth: >> and << splice leading whitespace rather than emitting Edit::SetDepth and reparenting.

Caps::RAW_TEXT and Caps::OUTLINE are the two preset shapes. A capability with no stated consequence does not belong in the struct: it would be a knob nobody reads, and hosts would set it by guess.

Multi-cursor is first-class, and real vim cannot be its oracle. Stock behavior is settled by differential test against Neovim rather than by memory of it: vim-oracle drives a real nvim over msgpack-rpc and commits what it did, 210 vectors so far, and the engine-versus-golden comparison is written and waiting for an engine to run it against. Vim has no multi-cursor, so that oracle can say nothing about it; multi-cursor correctness rests on stated invariants and a property suite instead. That gap is acknowledged rather than papered over.

Layout

In the tree today:

crates/
  vim-types    the frozen contract, and the only crate everything else
               depends on. `no_std` + `alloc`, zero dependencies.
                 positions   LineId, Pos, Bias, Anchor, Span
                 lines       LineKind, LineMeta, HydrateLine, IndexLine
                 document    the Document trait, Caps
                 change      Text, Edit, EditBatch, Delta, RemoteCause
                 remapping   remap_anchor, the normative rule as code
                 keys        KeyCode, Key
                 grammar     Mode, ModeSet, VisualKind, MotionId, ObjectId,
                             OperatorId, ActionId, ArgSpec, Args, InsertAt,
                             Target, Cmd, Program, Feed
                 output      CursorView, Status, Clip, PromptKind, Effect,
                             FeedResult
                 test double VecDoc: applies an EditBatch, emits the Deltas a
                             real host would report, and remaps anchors
                             through them, so a lane can drive the whole
                             edit/confirm/remap loop with no host at all
  vim-core     the registry, and the Motion / TextObject / Operator / Action /
               Extension traits. Signatures only: the bodies are
               `unimplemented!()` on purpose, and the crate is explicitly not
               frozen.
  vim-oracle   dev-only, depends on nothing else here: drives real Neovim over
               msgpack-rpc, 210 committed golden vectors, plus the
               engine-versus-golden comparison and its divergence report.

Outside crates/:

ts/            `@indexable/vim`, the TypeScript view of `wit/vim.wit`.
  generated/   emitted by `jco types`, committed, regenerated and diffed in
               CI. Never hand-edited.
  src/         the shim: brands for the flattened handles, the line-id
               constructors, and the contract invariants as doc comments.
  test/        type-level checks. `tsc --noEmit` is the whole suite, and every
               `@ts-expect-error` in it is a control that fires if a brand
               stops biting.

Named, designed against, not yet written:

  vim-machine     the pure state machine: keymap trie, grammar, dot/macro recording
  vim-engine      Engine<D>: delta ingestion, anchor remapping, registers, cursors
  vim-builtins    stock vim, written against the public registry API only
  vim-surround    the proof extension
  vim-text        raw-text host over a rope; the native reference embedding
  vim-blocks      block-tree host
  vim-component   WASM component guest + JS host example
  vim             facade: `Engine::with_defaults()`

Provenance

The behavior of vim-surround is reimplemented from tpope/vim-surround's documented behavior. No VimScript from that project is vendored or transliterated; it is Vim-license charityware and its code is not compatible with this repository's licensing.

License

MIT OR Apache-2.0, at your option.

About

A composable, embeddable vim engine in Rust: pluggable extensions, document-shape agnostic, first-class multi-cursor.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages