Open-Source Wikis

/

Helix

/

Primitives

/

Transaction

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:

  1. History commit (with the inverse).
  2. Selection update (selection.map(changes) for every view's selection).
  3. Tree-sitter incremental re-parse.
  4. LSP didChange notification.
  5. Diff handle update.
  6. Event dispatch (DocumentDidChange, possibly SelectionDidChange).

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-insert rollback.
  • Mode::Insert snippet 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) so apply is fast even for many cursors.
  • Inserts use Tendril (small-string optimized), so single-character insertions don't allocate.
  • Selection::map is 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. WorkspaceEdit from 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

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Transaction – Helix wiki | Factory