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
A language module must provide:
-
package() -> PluginPackage— registers the attach command, thelang.<id>.attachedhook declaration, and abuffer.file-openhook 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:
| Module | Language | Extensions | Notes |
|---|---|---|---|
c.rs | C | .c, .h | |
cpp.rs | C++ | .cpp, .cc, .cxx, .hpp | |
csharp.rs | C# | .cs | |
css.rs | CSS | .css | |
gitcommit.rs | Git Commit | COMMIT_EDITMSG | Syntax-only; no plugin package |
go.rs | Go | .go | |
html.rs | HTML | .html, .htm | |
javascript.rs | JavaScript / JSX | .js, .mjs, .jsx | Two variants: JS and JSX |
json.rs | JSON | .json | |
make.rs | Makefile | Makefile, .mk | |
markdown.rs | Markdown | .md, .markdown | Two variants: block and inline |
odin.rs | Odin | .odin | |
python.rs | Python | .py | |
rust.rs | Rust | .rs | Custom highlight query |
scss.rs | SCSS | .scss | |
sql.rs | SQL | .sql | |
toml.rs | TOML | .toml | |
typescript.rs | TypeScript / TSX | .ts, .tsx | Two variants: TS and TSX |
yaml.rs | YAML | .yaml, .yml | |
zig.rs | Zig | .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:
| Import | Purpose |
|---|---|
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 fullPluginPackagewith one hook binding per extension and oneworkspace.formatter.registeraction per formatter. -
common::syntax_language(language_id, extensions, repository, install_dir, symbol)— creates aLanguageConfigurationwith 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
| Parameter | Type | Example | Description |
|---|---|---|---|
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
| Parameter | Example | Description |
|---|---|---|
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:
| Method | Description |
|---|---|
.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 capture | Theme token |
|---|---|
attribute | syntax.attribute |
comment | syntax.comment |
constant | syntax.constant |
constant.builtin | syntax.constant.builtin |
constructor | syntax.constructor |
function | syntax.function |
function.builtin | syntax.function.builtin |
function.method | syntax.function.method |
keyword | syntax.keyword |
keyword.directive | syntax.keyword.directive |
label | syntax.label |
module | syntax.module |
number | syntax.number |
operator | syntax.operator |
property | syntax.property |
punctuation.bracket | syntax.punctuation.bracket |
punctuation.delimiter | syntax.punctuation.delimiter |
string | syntax.string |
string.escape | syntax.string.escape |
string.special | syntax.string.special |
tag | syntax.tag |
tag.attribute | syntax.tag.attribute |
type | syntax.type |
type.builtin | syntax.type.builtin |
variable | syntax.variable |
variable.builtin | syntax.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-usersucceeds with no warningscargo test -p volt-userpasses all tests- The new package name appears in
cargo run -p volt -- --bootstrap-demo cargo run -p volt -- --shell-hiddenexits cleanly (exit 0)cargo xtask cipasses end-to-end