← Back to DevBytes

Helix Extensions/Plugins: Complete Guide

Understanding Helix Extensions and Plugins

Helix is a modal text editor built in Rust that prioritizes performance, modern features, and a batteries-included philosophy. Unlike older editors that grew organically over decades, Helix was designed from the ground up with extensibility in mind. The extensions and plugins system allows developers to customize nearly every aspect of the editor — from language support and syntax highlighting to entirely new editing behaviors via WebAssembly plugins. Understanding this system is essential for anyone who wants to tailor Helix to their workflow or contribute new capabilities to the editor's ecosystem.

What Are Helix Extensions?

In Helix, the term "extensions" encompasses several distinct mechanisms for adding functionality:

Each of these extension types serves a different purpose and uses a different mechanism, but they all work together to create a fully customizable editing environment.

Why Extensibility Matters in Helix

Helix ships with support for a large number of languages out of the box, but the programming world is constantly evolving. New languages, frameworks, and tools emerge regularly. A robust extension system means the community can add support for new technologies without waiting for changes to be merged into the core editor. It also allows individual developers to create workflows that match their specific preferences — a Python developer working with Django needs different tooling than a Rust developer building embedded systems. Extensions bridge the gap between a general-purpose editor and a specialized development environment.

Language Extensions: Configuring languages.toml

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The primary mechanism for adding or customizing language support in Helix is the languages.toml file. This file defines language grammars, LSP servers, formatters, and auto-detection rules. Helix loads language configurations from two locations: the built-in configuration shipped with the editor, and user-defined configurations that can extend or override the defaults.

Location and Structure

User language configurations are placed in ~/.config/helix/languages.toml on Linux and macOS, or %AppData%/helix/languages.toml on Windows. The file uses TOML format and can define new languages or override existing ones. Here is the basic structure:

# ~/.config/helix/languages.toml

[[language]]
name = "mycustomlang"
scope = "source.mycustomlang"
file-types = ["mcl", "mycustomlang"]
injection-regex = "mycustomlang|mcl"
roots = ["Cargo.toml", "package.json"]
language-id = "mycustomlang"

[language.indent]
tab-width = 4
unit = "    "

[language.auto-pairs]
'(' = ')'
'{' = '}'
'[' = ']'
'"' = '"'

[[grammar]]
name = "mycustomlang"
source = { git = "https://github.com/user/tree-sitter-mycustomlang", rev = "main" }

[[language]]
name = "python"
language-id = "python"

[language.config]
# Override default Python configuration
python.linting.enabled = true

[language.formatter]
command = "black"
args = ["--line-length", "88", "-"]

[[language.lsp]]
name = "pyright"
command = "pyright-langserver"
args = ["--stdio"]
config = {}

This example demonstrates several key capabilities: defining a completely new language with its tree-sitter grammar source, overriding an existing language (Python) to add a formatter and LSP, and configuring language-specific settings.

Grammar Sources

Tree-sitter grammars are the foundation of Helix's language understanding. When adding a new language, you must specify where Helix can find and build the grammar. Helix supports fetching grammars from Git repositories and will automatically compile them on startup if they aren't already built:

[[grammar]]
name = "nix"
source = { git = "https://github.com/cstrahan/tree-sitter-nix", rev = "master", subpath = "grammar" }

The subpath field is useful when the grammar lives in a subdirectory of the repository. Helix caches compiled grammars in its runtime directory, so subsequent startups are fast.

LSP Integration

Helix integrates deeply with Language Server Protocol implementations. You can configure multiple LSP servers for a single language, and Helix will multiplex between them. Here's a more complex LSP configuration for a language like Rust that uses multiple servers:

[[language.lsp]]
name = "rust-analyzer"
command = "rust-analyzer"
args = []
config = {
  check = { command = "clippy" },
  inlayHints = { bindingModeHints = { enable = true } },
  procMacro = { enable = true }
}

[[language.lsp]]
name = "bacon-ls"
command = "bacon-ls"
args = ["--stdio"]

Helix will start all configured LSP servers for a language and merge their diagnostics, completions, and other capabilities seamlessly.

Formatter Configuration

Helix supports external formatters that run on save or on demand. You can chain multiple formatters and specify different formatters for different file patterns:

[language.formatter]
command = "prettier"
args = ["--parser", "typescript", "--stdin-filepath", "file.ts"]

[[language.formatter]]
command = "eslint"
args = ["--fix", "--stdin", "--stdin-filepath", "file.ts"]

Formatters are executed in the order they appear, piping the document content through each one sequentially.

Theme Extensions: Crafting Custom Color Schemes

Helix themes are defined as TOML files that map semantic tokens and UI elements to colors and modifiers. Themes live in the ~/.config/helix/themes/ directory and are automatically discovered by Helix on startup.

Creating a Custom Theme

A theme file defines colors for every aspect of the editor interface. Here is a complete example of a minimal theme:

# ~/.config/helix/themes/my-theme.toml

[theme]
name = "My Custom Theme"

[palette]
background = "#1e1e2e"
foreground = "#cdd6f4"
selection = "#45475b"
comment = "#6c7086"
red = "#f38ba8"
orange = "#fab387"
yellow = "#f9e2af"
green = "#a6e3a1"
blue = "#89b4fa"
purple = "#cba6f7"
cyan = "#94e2d5"

[ui]
background = { bg = "background" }
foreground = { fg = "foreground" }
cursor = { fg = "background", bg = "foreground" }
selection = { bg = "selection" }
linenr = { fg = "comment" }
linenr.selected = { fg = "foreground", bg = "background", modifiers = ["bold"] }
statusline = { fg = "foreground", bg = "background" }
statusline.inactive = { fg = "comment", bg = "background" }

[ui.menu]
background = { bg = "background" }
foreground = { fg = "foreground" }
selected = { bg = "selection", modifiers = ["bold"] }

[hints]
comment = { fg = "comment", modifiers = ["italic"] }
string = { fg = "green" }
number = { fg = "orange" }
keyword = { fg = "purple", modifiers = ["bold"] }
function = { fg = "blue" }
variable = { fg = "foreground" }
type = { fg = "yellow" }
constant = { fg = "cyan" }

The [palette] section defines named colors that can be referenced throughout the theme. The [ui] section covers interface elements like the statusline, line numbers, and cursor. The [hints] section maps tree-sitter node types to colors — these are the semantic tokens that Helix highlights in your code.

Advanced Theme Techniques

Themes can use modifiers to create rich visual effects. Available modifiers include bold, italic, dim, underline, strikethrough, and crossed-out. You can also define scoped UI elements for specific panels:

[ui.picker]
background = { bg = "background" }
foreground = { fg = "foreground" }
selected = { bg = "selection", fg = "purple", modifiers = ["bold"] }

[ui.picker.prompt]
foreground = { fg = "blue", modifiers = ["italic"] }

The ui.picker scope controls the file picker and symbol picker interfaces, while ui.picker.prompt specifically targets the prompt text within those pickers. This scoped approach gives precise control over every pixel of the interface.

Tree-sitter Query Extensions

Tree-sitter queries are the mechanism Helix uses to extract information from parsed syntax trees. Queries power syntax highlighting, code folding, indentation, symbol detection, and language injections (embedded languages within files). Helix allows users to extend or override these queries on a per-language basis.

Query File Locations

Custom queries are placed in the runtime directory under ~/.config/helix/runtime/queries/ in language-specific subdirectories. The directory structure mirrors Helix's built-in runtime:

~/.config/helix/runtime/queries/
├── rust/
│   ├── highlights.scm
│   ├── injections.scm
│   └── locals.scm
├── python/
│   ├── highlights.scm
│   └── folds.scm
└── markdown/
    └── injections.scm

Each .scm file contains Scheme-style query patterns that match against the tree-sitter concrete syntax tree.

Extending Highlights Queries

Highlight queries assign semantic tags to syntax nodes. If Helix's built-in highlighting for a language misses certain patterns, you can add them with a custom highlights file. Here's an example that adds highlighting for a custom decorator in Python:

; ~/.config/helix/runtime/queries/python/highlights.scm
; Extends built-in Python highlights

(decorator
  (call
    function: (attribute
      object: (identifier) @variable
      attribute: (identifier) @function)
    arguments: (argument_list
      (string) @string)))

This query matches decorators that take string arguments and assigns appropriate semantic tags. Helix merges custom queries with built-in ones, so you don't need to duplicate existing patterns — just add what's missing.

Injection Queries for Embedded Languages

One of Helix's most powerful features is handling embedded languages — JavaScript in HTML, SQL in Python strings, or templating languages within markup. Injection queries tell Helix how to identify and parse these embedded sections. Here's a custom injection query for recognizing GraphQL in tagged template literals:

; ~/.config/helix/runtime/queries/javascript/injections.scm

(call_expression
  function: (identifier) @_name
  arguments: (template_string) @injection
  (#eq? @_name "gql")
  (#set! injection.language "graphql"))

(call_expression
  function: (member_expression
    object: (identifier) @_obj
    property: (property_identifier) @_prop)
  arguments: (template_string) @injection
  (#eq? @_obj "graphql")
  (#eq? @_prop "query")
  (#set! injection.language "graphql"))

This pattern catches both gql`...` and graphql.query`...` patterns and tells Helix to parse the template string contents as GraphQL. The injected language gets full syntax highlighting, completion, and LSP support if a GraphQL language server is configured.

Folds and Indents Queries

Custom fold queries define which nodes can be collapsed in the editor. Indent queries control automatic indentation behavior. Here's an example for a hypothetical language:

; folds.scm - defines foldable regions
[
  (class_declaration)
  (function_declaration)
  (if_statement)
  (for_statement)
  (block)
] @fold

; indents.scm - defines indent-sensitive nodes
[
  (block)
  (class_body)
  (function_body)
] @indent

(outdent) @outdent

The @fold capture marks nodes that can be folded. The @indent capture increases indentation for child nodes, and @outdent decreases it. Helix uses these queries to power its smart indentation and code folding features.

WASM Plugins: Programmatic Extensions

The most powerful extension mechanism in Helix is the WebAssembly plugin system. WASM plugins are compiled modules that can hook into editor events, manipulate buffers, access LSP data, and extend editor functionality with arbitrary code. Plugins are written in any language that compiles to WASM (Rust, C, Zig, AssemblyScript, etc.) and communicate with Helix through a defined host interface.

Plugin Architecture Overview

Helix plugins run in a sandboxed WASM runtime. They have access to a controlled set of host functions for interacting with the editor. The plugin lifecycle consists of initialization, event handling, and cleanup. Plugins register callbacks for specific editor events and respond with actions that Helix executes on their behalf.

A typical plugin structure in Rust looks like this:

// Cargo.toml
[package]
name = "my-helix-plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
helix-plugin = "0.1"
serde = { version = "1", features = ["derive"] }

// src/lib.rs
use helix_plugin::{Plugin, Editor, Event, Action, Result};
use serde::{Deserialize, Serialize};

#[derive(Default)]
struct MyPlugin {
    counter: u32,
}

impl Plugin for MyPlugin {
    fn name(&self) -> &str {
        "my-counter-plugin"
    }

    fn version(&self) -> &str {
        "0.1.0"
    }

    fn init(&mut self, editor: &Editor) -> Result<()> {
        editor.register_event(Event::DocumentSave, "on_save")?;
        editor.register_event(Event::CursorMove, "on_cursor_move")?;
        Ok(())
    }

    fn handle_event(&mut self, editor: &Editor, event: Event, name: &str) -> Result> {
        match name {
            "on_save" => {
                self.counter += 1;
                editor.show_message(&format!("Saved {} times in this session", self.counter))?;
                Ok(None)
            }
            "on_cursor_move" => {
                let pos = editor.cursor_position()?;
                let line_count = editor.line_count()?;
                let percentage = (pos.row as f64 / line_count as f64) * 100.0;
                editor.set_status_message(&format!("Line {} of {} ({:.1}%)", pos.row + 1, line_count, percentage))?;
                Ok(None)
            }
            _ => Ok(None),
        }
    }

    fn cleanup(&mut self, editor: &Editor) -> Result<()> {
        editor.show_message("Plugin unloaded. Goodbye!")?;
        Ok(())
    }
}

#[no_mangle]
pub fn register_plugin() -> Box {
    Box::new(MyPlugin::default())
}

This plugin demonstrates several key concepts: registering for editor events, maintaining internal state, querying editor information, and displaying feedback to the user. The #[no_mangle] export register_plugin is the entry point Helix calls to instantiate the plugin.

Building and Installing WASM Plugins

To build a Rust plugin for Helix, you need the wasm32-unknown-unknown target and a build pipeline. Here's the complete build process:

# Install the WASM target
rustup target add wasm32-unknown-unknown

# Build the plugin
cargo build --release --target wasm32-unknown-unknown

# The compiled plugin is at:
# target/wasm32-unknown-unknown/release/my_helix_plugin.wasm

# Install the plugin
mkdir -p ~/.config/helix/plugins/
cp target/wasm32-unknown-unknown/release/my_helix_plugin.wasm ~/.config/helix/plugins/

Helix automatically discovers plugins in the plugins/ directory. On startup, it loads each .wasm file, calls its register_plugin function, and runs the init method. Plugins are listed in the editor's plugin manager, accessible via :plugin-list.

Available Host API Functions

The Helix host provides a rich API for plugins. Here are the major categories of available functions:

// Buffer manipulation
editor.get_current_buffer() -> Buffer
editor.get_buffer_by_id(id: u32) -> Buffer
buffer.get_text() -> String
buffer.get_text_range(start: usize, end: usize) -> String
buffer.insert_text(pos: usize, text: &str) -> Result
buffer.replace_text(range: Range, text: &str) -> Result
buffer.delete_text(range: Range) -> Result

// Cursor and selection
editor.cursor_position() -> Position
editor.set_cursor_position(pos: Position) -> Result
editor.selection() -> Selection
editor.set_selection(sel: Selection) -> Result

// Editor state
editor.line_count() -> u32
editor.current_mode() -> Mode
editor.current_language() -> String
editor.file_path() -> Option

// LSP access
editor.lsp_request(method: &str, params: &str) -> Result
editor.lsp_diagnostics() -> Vec
editor.lsp_completions(pos: Position) -> Vec

// UI interaction
editor.show_message(msg: &str) -> Result
editor.show_error(msg: &str) -> Result
editor.prompt(msg: &str, default: &str) -> Result
editor.set_status_message(msg: &str) -> Result

// Configuration
editor.get_config(key: &str) -> Option
editor.set_config(key: &str, value: &str) -> Result

Plugins use these functions to build complex editing workflows. For example, a plugin could listen for the DocumentOpen event, check if the file is a Dockerfile, and automatically enable Dockerfile-specific linting or formatting rules.

Event-Driven Plugin Example: Auto-Format on Save

Here's a more practical plugin that runs a custom formatter on save and tracks formatting statistics:

use helix_plugin::{Plugin, Editor, Event, Action, Result};
use std::collections::HashMap;

#[derive(Default)]
struct FormatTracker {
    files_formatted: HashMap,
    total_formats: u32,
}

impl Plugin for FormatTracker {
    fn name(&self) -> &str { "format-tracker" }
    fn version(&self) -> &str { "0.1.0" }

    fn init(&mut self, editor: &Editor) -> Result<()> {
        editor.register_event(Event::DocumentPreSave, "before_save")?;
        editor.register_event(Event::DocumentPostSave, "after_save")?;
        Ok(())
    }

    fn handle_event(&mut self, editor: &Editor, event: Event, name: &str) -> Result> {
        match name {
            "before_save" => {
                let text = editor.get_current_buffer()?.get_text()?;
                let formatted = format_custom(&text);
                if formatted != text {
                    editor.get_current_buffer()?.replace_text(0..text.len(), &formatted)?;
                    editor.show_message("Applied custom formatting")?;
                }
                Ok(None)
            }
            "after_save" => {
                let path = editor.file_path().unwrap_or("unknown".to_string());
                let count = self.files_formatted.entry(path.clone()).or_insert(0);
                *count += 1;
                self.total_formats += 1;
                editor.set_status_message(
                    &format!("Formatted '{}' {} times | Total: {}",
                        path, count, self.total_formats)
                )?;
                Ok(None)
            }
            _ => Ok(None),
        }
    }
}

fn format_custom(text: &str) -> String {
    // Custom formatting logic here
    text.lines()
        .map(|l| l.trim_end())
        .collect::>()
        .join("\n")
}

#[no_mangle]
pub fn register_plugin() -> Box {
    Box::new(FormatTracker::default())
}

Plugin Configuration

Plugins can declare configurable options that users set in their Helix configuration. Here's how to define and access plugin configuration:

// In your plugin's init method:
fn init(&mut self, editor: &Editor) -> Result<()> {
    // Read plugin-specific configuration
    let indent_size: usize = editor.get_config("my-plugin.indent-size")
        .and_then(|v| v.parse().ok())
        .unwrap_or(4);

    let enable_linting: bool = editor.get_config("my-plugin.enable-linting")
        .and_then(|v| v.parse().ok())
        .unwrap_or(true);

    // Store in plugin state
    self.config.indent_size = indent_size;
    self.config.enable_linting = enable_linting;

    Ok(())
}

Users configure these options in their config.toml:

# ~/.config/helix/config.toml

[plugin.my-plugin]
indent-size = 2
enable-linting = true

[plugin.format-tracker]
show-statistics = true

Best Practices for Helix Extensions

Language Extensions Best Practices

Theme Development Best Practices

Tree-sitter Query Best Practices

WASM Plugin Best Practices

General Extension Development Workflow

A disciplined development workflow will save you hours of debugging. Here's a recommended approach for developing any Helix extension:

# 1. Set up a development configuration directory
export HELIX_RUNTIME=~/.config/helix-dev/

# 2. Enable verbose logging
# In config.toml:
verbose = true

# 3. Use :log-open to watch logs in real-time
# 4. Test incrementally - change one setting at a time
# 5. Use :reload-all to apply changes without restarting

The HELIX_RUNTIME environment variable lets you point Helix at a separate configuration directory for development, keeping your production configuration intact. The :reload-all command hot-reloads themes, language configurations, and queries without restarting the editor.

Conclusion

Helix's extension system is thoughtfully designed to cover the full spectrum of editor customization needs. Language configurations provide declarative integration with the broader development ecosystem — tree-sitter grammars, LSP servers, and formatters. Theme definitions give pixel-perfect control over the editor's appearance. Tree-sitter queries unlock deep structural understanding of code for features like highlighting, folding, and embedded language support. And the WASM plugin system opens the door to programmatic editor extensions that can respond to events, manipulate buffers, and integrate with external services — all while running in a secure, sandboxed environment.

The key to mastering Helix extensions is understanding which mechanism fits your goal. For adding a new language, reach for languages.toml. For visual customization, create a theme. For fixing highlighting or adding injection support, write tree-sitter queries. And for interactive, event-driven functionality, build a WASM plugin. Each layer builds on the ones below it, creating a cohesive extensibility stack that rewards investment at every level. With the knowledge from this guide and the practices outlined above, you are equipped to shape Helix into the perfect editor for your development needs.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles