Open-Source Wikis

/

Helix

/

How to contribute

/

Patterns and conventions

helix-editor/helix

Patterns and conventions

This page collects the recurring idioms, error handling style, and architectural patterns you will see throughout the Helix codebase.

Functional core, imperative shell

The helix-core crate is intentionally functional: most operations return new values rather than mutating in place. The Rope is cheap to clone, transactions are immutable, and selections are mapped through transactions to produce new selections. The imperative shell lives in helix-view (mutable Editor/Document/View) and helix-term (the event loop and UI).

This split is documented in docs/architecture.md and reflected in the crate dependency graph: helix-core does not depend on helix-view, helix-tui, or anything UI-shaped.

Transactions are the unit of edit

The codebase rarely mutates a Rope directly. Instead, it builds a Transaction from selection-aware change generators (Transaction::change, change_by_selection, insert, delete) and applies it through Document::apply (helix-view/src/document.rs).

let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
    let from = range.from();
    let to = range.to();
    (from, to, Some("hello".into()))
});
doc.apply(&transaction, view.id);

Why: transactions can be inverted (for undo), composed, mapped over (so positions in the old buffer translate into positions in the new one), and recorded. The History type (helix-core/src/history.rs) stores undo trees of transactions.

Selections are first-class

Most edits are written as functions of the current Selection. The Range type carries anchor and head; Selection::primary() returns the active range. When extending behavior, prefer change_by_selection, Selection::transform, and range.put_cursor over per-cursor loops that re-implement these patterns. See helix-core/src/selection.rs.

Event hooks instead of god callbacks

To react to editor state changes (a document was opened, a selection moved, the focused view changed) the codebase uses synchronous hooks from helix-event:

register_hook!(move |event: &mut DocumentDidChange<'_>| {
    // synchronous, sees mutable event payload
    Ok(())
});

For debounced or async work (LSP completion, document highlights) use AsyncHook from helix-event/src/debounce.rs. The hook produces an event sink; events are coalesced and processed off the main thread.

Editor events are declared with the events! macro and live next to the subsystem that owns them (e.g. helix-view/src/events.rs, helix-term/src/events.rs).

Compositor Component trait

Anything that draws and handles input in the terminal frontend implements Component:

fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult;
fn render(&mut self, area: Rect, frame: &mut Surface, ctx: &mut Context);
fn cursor(&self, area: Rect, ctx: &Editor) -> (Option<Position>, CursorKind);

EventResult::Ignored lets the next layer down receive the event; Consumed stops propagation. Use Component::id for stable lookup of layered components.

Errors and panics

  • Use anyhow::Result for fallible code in helix-term and helix-view.
  • Use thiserror to define error enums in protocol crates (helix-lsp, helix-dap, helix-vcs).
  • Panicking is reserved for invariant violations — never for user input.
  • unwrap/expect is acceptable when the precondition is locally provable (e.g. a NonZeroUsize::new(1).unwrap()).
  • For user-facing errors, prefer editor.set_error("…") over eprintln!. The status bar surfaces these.

Configuration

User TOML deserializes into typed structs via serde. The pattern is:

  1. Default values implement Default in Rust.
  2. Deserialize is derived with #[serde(default, deny_unknown_fields)].
  3. Two configs (global + workspace-local) are loaded and shallow-merged with helix_loader::merge_toml_values. See helix-term/src/config.rs.

When adding a config field, add a default, document it in book/src/editor.md, and run cargo xtask docgen if it shows up in generated docs.

Async everywhere, but on a single main loop

helix-term::Application::run is async fn. Work that should not block typing is spawned via tokio::spawn and reports back through:

  • Jobs (helix-term/src/job.rs) — Future<Output = anyhow::Result<Callback>> queued to run on the main loop with mutable access to the editor and compositor.
  • helix_event::status — push transient status-line messages from any task.
  • helix_event::request_redraw — tell the main loop to draw a new frame.

Avoid block_on outside of explicit tokio::task::block_in_place regions like Context::block_try_flush_writes.

Naming

  • Files: snake_case.rs. Sub-modules use directories with mod.rs only when there are multiple files; otherwise prefer flat modules.
  • Types: UpperCamelCase.
  • Functions, methods, and modules: snake_case.
  • Identifier suffixes:
    • _id for opaque IDs (DocumentId, ViewId, LanguageServerId, DebugAdapterId).
    • *Config for deserializable settings structs.
    • *Event for hook payloads.

Re-exports

Top-level lib.rs files re-export the most-used types. For instance, helix-core/src/lib.rs re-exports Rope, Selection, Transaction, Position, etc., so downstream crates can use helix_core::Selection. When adding a new commonly-used type, follow that pattern instead of forcing every caller to know the inner module.

Logging

Use log::{trace, debug, info, warn, error} with structured-ish messages. The verbosity level is set via -v flags in main.rs::setup_logging. Logs go to a per-user file; :log-open opens it in Helix.

Tree-sitter queries live next to grammars

Per-language tree-sitter queries (highlights.scm, injections.scm, locals.scm, indents.scm, textobjects.scm, tags.scm) live in runtime/queries/<lang>/. Validation runs via cargo xtask query-check. Don't add a new language without these queries when applicable; the editor will fall back gracefully but features will degrade.

Tests

Don't reach across crates for internals

Cross-crate calls go through public APIs only. If a helix-term change wants something from helix-core, add a function in helix-core rather than reaching into private state. The crate boundaries are the architectural seam.

Avoid blocking I/O on the main loop

File reads, formatter invocations, and shell commands should be spawned through tokio::process::Command or tokio::fs. The exception is the synchronous flush in Context::block_try_flush_writes which intentionally blocks during shutdown.

Keep Cargo.toml minimal

Workspace-wide deps live in [workspace.dependencies] of the root Cargo.toml; per-crate Cargo.tomls use dep.workspace = true to inherit. Add new deps to the workspace table when more than one crate uses them.

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

Patterns and conventions – Helix wiki | Factory