helix-editor/helix
Transaction
A Transaction is the unit of edit in Helix. Every change to a buffer goes through one — including LSP edits, snippet expansion, find-and-replace, multi-cursor inserts, and undo/redo.
The implementation is in helix-core/src/transaction.rs.
Anatomy
pub struct Transaction {
changes: ChangeSet,
selection: Option<Selection>, // optional new selection after apply
}
pub struct ChangeSet {
pub(crate) changes: Vec<Operation>,
len: usize, // expected document length before applying
len_after: usize, // length after applying
}
pub enum Operation {
Retain(usize),
Delete(usize),
Insert(Tendril),
}A ChangeSet is a flat sequence of operations that, applied in order, fully describe the edit. The len field is checked on apply: a stale change set against a mutated rope will refuse to apply rather than corrupt state.
Building a transaction
The two main constructors:
Transaction::change(rope, [(0, 5, Some("hi".into()))].into_iter())
Transaction::change_by_selection(rope, &selection, |range| {
(range.from(), range.to(), Some(replacement.into()))
})change_by_selection is the multi-cursor workhorse: it runs the closure once per range and assembles a single ChangeSet. The change tuples must be sorted and non-overlapping; the constructor panics in debug builds otherwise.
Specialized helpers:
Transaction::insert(rope, &selection, text)— insert at every cursor.Transaction::delete(rope, &selection)— delete at every cursor.Transaction::change_by_selection_ignore_empty— like the main one but skips empty ranges.
Operations
Operation is an OT-style move/insert/delete:
| Operation | Effect |
|---|---|
Retain(n) |
Skip n chars (no edit). |
Delete(n) |
Delete the next n chars. |
Insert(text) |
Insert literal text at the cursor. |
A change set always sums to the document length: total retained + total deleted equals len (the input length); total retained + total inserted equals len_after.
Inverting
Transaction::invert(rope_before) produces a transaction that reverses the original edit when applied to the resulting rope. This is how undo works: each commit in History (helix-core/src/history.rs) stores both the forward transaction and a precomputed inverse.
Composing
ChangeSet::compose(other) produces a single change set equivalent to applying self then other. This is used by:
- Undo coalescing — sequential character inserts in insert mode are composed into one undoable transaction.
- Macro replay — N transactions of a recorded macro become one transaction.
Mapping positions
A ChangeSet knows how every char position in the input maps to a position in the output:
let new_pos = changes.map_pos(old_pos, Assoc::After);Assoc controls "which side of an inserted/deleted span do you want to land on?":
| Assoc | When to use |
|---|---|
Before |
Stick to the left of inserts; useful for marks. |
After |
Stick to the right; default for cursor head. |
BeforeWord / AfterWord |
Word-aware variants — used by completion to track the cursor at the end of typed text. |
BeforeSticky / AfterSticky |
For positions inside an exact-size replacement, preserve relative offset to the replacement start. |
Selection::map is built on top of Range::map, which calls map_pos for anchor and head with appropriate Assocs.
Reading a transaction
for change in transaction.changes_iter() {
let (from, to, replacement) = change; // (start_char, end_char, Option<Tendril>)
// …
}changes_iter walks the operations and yields tuples in source-position order — easier to consume than the raw Operation stream.
Application
Transaction::apply(&mut Rope) -> bool mutates the rope in place. Returns false if the rope length doesn't match len (refuse to corrupt). Document::apply (helix-view/src/document.rs) wraps this with:
- History commit (with the inverse).
- Selection update (
selection.map(changes)for every view's selection). - Tree-sitter incremental re-parse.
- LSP
didChangenotification. - Diff handle update.
- Event dispatch (
DocumentDidChange, possiblySelectionDidChange).
Skipping doc.apply and mutating the rope directly bypasses all of this — never do it outside helix-core tests.
Save points
Document::savepoint() snapshots the current state. Document::restore(savepoint) produces a transaction that returns the document to that state. Used by:
- The completion popup's
preview-completion-insertrollback. Mode::Insertsnippet placeholders (replaced text can be reverted on Esc).
The implementation is in document.rs under SavePoint.
Performance notes
- A change set's
Vec<Operation>is small (bounded by ~3× the number of selection ranges) soapplyis fast even for many cursors. - Inserts use
Tendril(small-string optimized), so single-character insertions don't allocate. Selection::mapis O(ranges × log(rope)) — multi-cursor edits scale linearly with cursor count, not document size.
Why this design
The transaction model — and the OT-style change set inside it — pays off everywhere:
- Undo is symmetric. Forward and inverse transactions have the same shape; redo is just "apply this thing".
- Selections survive edits. Mapping is well-defined for every position, so multi-cursor + auto-pair + undo all interact cleanly.
- LSP edits compose.
WorkspaceEditfrom a code action turns into a sequence of transactions; ranges within the same document are mapped through prior transactions to remain valid. - Macros compose. Replay is composition; recording is a stream of transactions.
See also
- primitives/rope — the data structure that's edited.
- primitives/selection — what
change_by_selectionoperates on. - packages/helix-core — surrounding modules.
Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.