Volt Docs Adding a language
← Back to Volt Docs

Language support guide

Adding a Language

Every language in Volt is a self-contained Rust module under user/lang/. It exports two functions: package() for the plugin metadata (hook bindings, commands, formatter registration) and syntax_language() for the tree-sitter grammar and theme-token mappings. This guide walks through both the shorthand helper path and the full manual implementation, then covers LSP registration and testing.

Overview

user/lang/ ├─ mod.rs — module declarations + packages() and syntax_languages() registries ├─ common.rs — shared helper that builds standard package() and syntax_language() ├─ rust.rs — full manual implementation (custom highlight query) ├─ python.rs — one-liner using common::package() and common::syntax_language() ├─ gitcommit.rs — syntax-only (no plugin package; not added to packages()) └─ <newlang>.rs — your new file goes here

A language module must provide:

  • package() -> PluginPackage — registers the attach command, the lang.<id>.attached hook declaration, and a buffer.file-open hook binding for each configured file matcher.
  • syntax_language() -> LanguageConfiguration — maps a tree-sitter grammar source and capture names to theme tokens.

After creating the file you add two lines to user/lang/mod.rs: a pub mod declaration and a call in each of the two registry functions. No changes to user/lib.rs are needed for a standard language module.

Built-in Languages

The following languages ship with Volt and serve as reference implementations:

ModuleLanguageExtensionsNotes
c.rsC.c, .h
cpp.rsC++.cpp, .cc, .cxx, .hpp
csharp.rsC#.cs
css.rsCSS.css
gitcommit.rsGit CommitCOMMIT_EDITMSGSyntax-only; no plugin package
go.rsGo.go
html.rsHTML.html, .htm
javascript.rsJavaScript / JSX.js, .mjs, .jsxTwo variants: JS and JSX
json.rsJSON.json
make.rsMakefileMakefile, .mk
markdown.rsMarkdown.md, .markdownTwo variants: block and inline
odin.rsOdin.odin
python.rsPython.py
rust.rsRust.rsCustom highlight query
scss.rsSCSS.scss
sql.rsSQL.sql
toml.rsTOML.toml
typescript.rsTypeScript / TSX.ts, .tsxTwo variants: TS and TSX
yaml.rsYAML.yaml, .yml
zig.rsZig.zig

Module Layout

The minimal file structure for a language module called lua:

user/lang/lua.rs          ← new file you create
user/lang/mod.rs          ← add pub mod + two registry entries

A language module imports from two crates:

ImportPurpose
editor_plugin_api::{PluginPackage, …} Plugin metadata types (commands, hooks, keybindings)
editor_syntax::{GrammarSource, LanguageConfiguration, …} Syntax registration and tree-sitter theme mappings

Using the Common Helper

Most languages follow an identical pattern, so user/lang/common.rs provides two helper functions that cover the standard case:

  • common::package(language_id, display_name, extensions, formatters) — builds the full PluginPackage with one hook binding per extension and one workspace.formatter.register action per formatter.
  • common::syntax_language(language_id, extensions, repository, install_dir, symbol) — creates a LanguageConfiguration with the standard set of capture mappings.

Example — Adding Lua with the common helper

Create user/lang/lua.rs:

use editor_plugin_api::PluginPackage;
use editor_syntax::LanguageConfiguration;

use super::common;

/// Lua language support and theme mappings.
pub fn package() -> PluginPackage {
    common::package("lua", "Lua", &["lua"], &["lua|stylua"])
}

/// Returns the syntax registration for the Lua tree-sitter language.
pub fn syntax_language() -> LanguageConfiguration {
    common::syntax_language(
        "lua",
        &["lua"],
        "https://github.com/tree-sitter-grammars/tree-sitter-lua.git",
        "tree-sitter-lua",
        "tree_sitter_lua",
    )
}

common::package parameter reference

ParameterTypeExampleDescription
language_id &str "lua" Lowercase identifier. Used in the package name (lang-lua), command name (lang-lua.attach), and hook name (lang.lua.attached).
display_name &str "Lua" Human-readable name used in log messages and descriptions.
extensions &[&str] &["lua"] File extensions (without the dot) that trigger auto-attach. Language modules can also subscribe by exact basename or glob when needed.
formatters &[&str] &["lua|stylua"] Formatter registration strings in "lang|tool" format. Each emits a workspace.formatter.register hook action. Pass an empty slice if there is no formatter.

common::syntax_language parameter reference

ParameterExampleDescription
language_id "lua" Tree-sitter language name (matches the package name used by the runtime).
extensions &["lua"] File extensions that select this language for syntax highlighting.
repository "https://github.com/…/tree-sitter-lua.git" Git URL of the tree-sitter grammar repository.
install_dir_name "tree-sitter-lua" Directory name the grammar is cloned into under the Volt grammar directory ($XDG_DATA_HOME/volt/grammars on Linux, %LOCALAPPDATA%\volt\grammars on Windows).
symbol_name "tree_sitter_lua" Name of the exported C symbol in the compiled grammar (e.g. tree_sitter_lua expands to the tree_sitter_lua() function).

Full Manual Implementation

Use the manual form when you need any of the following:

  • A custom tree-sitter highlight query (injections, extra captures)
  • Multiple grammar variants for the same language (e.g. JSX, TSX)
  • A syntax-only registration with no plugin package (e.g. gitcommit)
  • Custom hook declarations beyond lang.<id>.attached

Full package() implementation

use editor_plugin_api::{
    PluginAction, PluginCommand, PluginHookBinding,
    PluginHookDeclaration, PluginPackage,
};
use editor_syntax::{CaptureThemeMapping, GrammarSource, LanguageConfiguration};

pub fn package() -> PluginPackage {
    PluginPackage::new(
        "lang-lua",
        true,
        "Lua language defaults, tree-sitter mapping, and startup hooks.",
    )
    .with_commands(vec![PluginCommand::new(
        "lang-lua.attach",
        "Attaches Lua language defaults to the active workspace.",
        vec![
            PluginAction::log_message("Lua language package attached."),
            // Register the formatter; omit this line if no formatter exists.
            PluginAction::emit_hook("workspace.formatter.register", Some("lua|stylua")),
            // Emit the language-attached lifecycle hook.
            PluginAction::emit_hook("lang.lua.attached", Some("lua")),
        ],
    )])
    .with_hook_declarations(vec![PluginHookDeclaration::new(
        "lang.lua.attached",
        "Runs after the Lua language package attaches to a buffer.",
    )])
    .with_hook_bindings(vec![
        // Auto-attach when a .lua file opens.
        PluginHookBinding::new(
            "buffer.file-open",
            "lang-lua.auto-attach",
            "lang-lua.attach",
            Some(".lua"),
        ),
    ])
}

Full syntax_language() with an extra highlight query

// Optional: an extra tree-sitter query that supplements the grammar's
// built-in highlights.scm file.
const EXTRA_HIGHLIGHT_QUERY: &str = r#"
[
  "(" ")" "[" "]" "{" "}"
] @punctuation.bracket

[
  "," ";" ":"
] @punctuation.delimiter
"#;

pub fn syntax_language() -> LanguageConfiguration {
    LanguageConfiguration::from_grammar(
        "lua",
        ["lua"],
        GrammarSource::new(
            "https://github.com/tree-sitter-grammars/tree-sitter-lua.git",
            ".",
            "src",
            "tree-sitter-lua",
            "tree_sitter_lua",
        ),
        [
            CaptureThemeMapping::new("comment",              "syntax.comment"),
            CaptureThemeMapping::new("constant",             "syntax.constant"),
            CaptureThemeMapping::new("constant.builtin",     "syntax.constant.builtin"),
            CaptureThemeMapping::new("function",             "syntax.function"),
            CaptureThemeMapping::new("function.builtin",     "syntax.function.builtin"),
            CaptureThemeMapping::new("function.method",      "syntax.function.method"),
            CaptureThemeMapping::new("keyword",              "syntax.keyword"),
            CaptureThemeMapping::new("number",               "syntax.number"),
            CaptureThemeMapping::new("operator",             "syntax.operator"),
            CaptureThemeMapping::new("property",             "syntax.property"),
            CaptureThemeMapping::new("punctuation.bracket",  "syntax.punctuation.bracket"),
            CaptureThemeMapping::new("punctuation.delimiter","syntax.punctuation.delimiter"),
            CaptureThemeMapping::new("string",               "syntax.string"),
            CaptureThemeMapping::new("type",                 "syntax.type"),
            CaptureThemeMapping::new("variable",             "syntax.variable"),
            CaptureThemeMapping::new("variable.builtin",     "syntax.variable.builtin"),
        ],
    )
    .with_extra_highlight_query(EXTRA_HIGHLIGHT_QUERY)
}

Syntax-only registration (no plugin package)

If a language only needs syntax highlighting and no hook or formatter integration, omit package() entirely and skip the packages() entry in mod.rs. Only add syntax_language() to the syntax_languages() registry. The gitcommit module in the Volt source is an example of this pattern.

// user/lang/gitcommit.rs
use editor_syntax::{CaptureThemeMapping, GrammarSource, LanguageConfiguration};

pub fn syntax_language() -> LanguageConfiguration {
    LanguageConfiguration::from_grammar(
        "gitcommit",
        ["COMMIT_EDITMSG"],
        GrammarSource::new(
            "https://github.com/gbprod/tree-sitter-gitcommit.git",
            ".",
            "src",
            "tree-sitter-gitcommit",
            "tree_sitter_gitcommit",
        ),
        [
            CaptureThemeMapping::new("comment", "syntax.comment"),
            CaptureThemeMapping::new("keyword", "syntax.keyword"),
            CaptureThemeMapping::new("string",  "syntax.string"),
        ],
    )
}

Multiple variants — JSX / TSX pattern

Export additional functions when one module registers multiple grammar variants (e.g. TypeScript and TSX both live in user/lang/typescript.rs):

/// Base TypeScript syntax.
pub fn syntax_language() -> LanguageConfiguration { /* … */ }

/// TSX variant (React).
pub fn tsx_syntax_language() -> LanguageConfiguration { /* … */ }

Register both variants in syntax_languages() inside mod.rs — they share the same module declaration but each maps different extensions.


Registering the Module

Open user/lang/mod.rs and make three edits:

Step 1 — Declare the module

Add a pub mod declaration in alphabetical order with the other languages:

/// Lua language support and theme mappings.
pub mod lua;

Step 2 — Add to packages()

Inside the packages() function, add a call to your module’s package() function. Maintain alphabetical order:

pub fn packages() -> Vec<editor_plugin_api::PluginPackage> {
    vec![
        // … existing entries …
        lua::package(),
        // … existing entries …
    ]
}

Skip this step if your module is syntax-only and has no package().

Step 3 — Add to syntax_languages()

Add a call to syntax_language() in the same function:

pub fn syntax_languages() -> Vec<LanguageConfiguration> {
    vec![
        // … existing entries …
        lua::syntax_language(),
        // … existing entries …
    ]
}

If the module exports multiple variants (like JSX / TSX), add one call per variant:

typescript::syntax_language(),
typescript::tsx_syntax_language(),

That is everything required for a standard language. No changes to user/lib.rs are needed — the lang::packages() and lang::syntax_languages() calls are already wired in.


Adding an LSP Server

Language server support is declared separately in user/lsp.rs. Open that file and add a new LanguageServerSpec entry to the language_servers() function:

LanguageServerSpec::new(
    "lua-language-server",     // unique identifier
    "lua",                     // language_id — must match the id in package()
    "lua-language-server",     // executable name (must be on PATH)
)

The spec supports optional builder methods for extra configuration:

MethodDescription
.with_args(vec!["--stdio"]) Command-line arguments forwarded to the server process
.with_initialization_options(json!({…})) JSON blob sent in the LSP initialize request

The LSP session is started lazily when a file matching the language ID is opened, so no additional wiring is needed.


Theme Tokens Reference

Each CaptureThemeMapping maps a tree-sitter capture name (left) to a theme token key (right). The token keys must be defined in user/theme.rs for them to produce color output. The common::syntax_language() helper automatically applies the full standard set listed below.

Tree-sitter captureTheme token
attributesyntax.attribute
commentsyntax.comment
constantsyntax.constant
constant.builtinsyntax.constant.builtin
constructorsyntax.constructor
functionsyntax.function
function.builtinsyntax.function.builtin
function.methodsyntax.function.method
keywordsyntax.keyword
keyword.directivesyntax.keyword.directive
labelsyntax.label
modulesyntax.module
numbersyntax.number
operatorsyntax.operator
propertysyntax.property
punctuation.bracketsyntax.punctuation.bracket
punctuation.delimitersyntax.punctuation.delimiter
stringsyntax.string
string.escapesyntax.string.escape
string.specialsyntax.string.special
tagsyntax.tag
tag.attributesyntax.tag.attribute
typesyntax.type
type.builtinsyntax.type.builtin
variablesyntax.variable
variable.builtinsyntax.variable.builtin

Only include mappings that the grammar’s highlights.scm actually emits — unmapped captures are silently ignored at runtime, but extra entries in the module add noise and slight compile-time cost.

Adding a new theme token

If a grammar uses a capture name not in the standard list above, add a corresponding entry in user/theme.rs. Find the syntax.* token block and insert the new key:

// In user/theme.rs — inside the token map:
("syntax.my-capture", Color::from_rgb(200, 150, 80)),

Then add the matching CaptureThemeMapping in your language module:

CaptureThemeMapping::new("my-capture", "syntax.my-capture"),

Building and Testing

Build the user library

# Fast incremental build — catches compile errors quickly
cargo build -p volt-user

# Build the full editor with the new language included
cargo build -p volt

Run the user-library tests

The volt-user crate contains unit tests that verify package registration, syntax language exports, and LSP defaults. Run them after every change:

# Run all user-library tests
cargo test -p volt-user

# Run only tests whose names match a pattern
cargo test -p volt-user user_library_exports

# Exact match
cargo test -p volt-user tests::user_library_exports_themes -- --exact

Smoke-test the editor

# One-frame headless run — verifies startup does not panic
cargo run -p volt -- --shell-hidden

# Bootstrap demo — prints every registered package and subsystem summary
cargo run -p volt -- --bootstrap-demo

The bootstrap demo output lists all packages, including your new lang-<id> entry. Confirm it appears and that no panics or error messages are printed.

Full CI validation

# Runs fmt-check, cargo check, clippy (-D warnings), and test
cargo xtask ci

All four steps must pass before submitting a new language module.

Checklist

  • cargo build -p volt-user succeeds with no warnings
  • cargo test -p volt-user passes all tests
  • The new package name appears in cargo run -p volt -- --bootstrap-demo
  • cargo run -p volt -- --shell-hidden exits cleanly (exit 0)
  • cargo xtask ci passes end-to-end