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::Resultfor fallible code inhelix-termandhelix-view. - Use
thiserrorto define error enums in protocol crates (helix-lsp,helix-dap,helix-vcs). - Panicking is reserved for invariant violations — never for user input.
unwrap/expectis acceptable when the precondition is locally provable (e.g. aNonZeroUsize::new(1).unwrap()).- For user-facing errors, prefer
editor.set_error("…")overeprintln!. The status bar surfaces these.
Configuration
User TOML deserializes into typed structs via serde. The pattern is:
- Default values implement
Defaultin Rust. Deserializeis derived with#[serde(default, deny_unknown_fields)].- Two configs (global + workspace-local) are loaded and shallow-merged with
helix_loader::merge_toml_values. Seehelix-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 withmod.rsonly when there are multiple files; otherwise prefer flat modules. - Types:
UpperCamelCase. - Functions, methods, and modules:
snake_case. - Identifier suffixes:
_idfor opaque IDs (DocumentId,ViewId,LanguageServerId,DebugAdapterId).*Configfor deserializable settings structs.*Eventfor 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
- Unit tests live next to the code in
mod tests. Use thehelix_core::testmodule helpers (helix-core/src/test.rs) for selection-aware assertions (#[1|]hello[|2]worldselection markers). - Integration tests live in
helix-term/tests/test/. They drive the application with aTestBackend. Helpers:helix-term/tests/test/helpers.rs.
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.