Ballerina support for Neovim — syntax highlighting, LSP, format-on-save,
auto-indent, and bal run/test/build, in one plugin.

Features · Requirements · Installation · Quick start · Commands · Configuration · Debugging · Troubleshooting · Grammar notes · Roadmap · Related · Contributing · License
Why not just nvim-lspconfig? LSP
is only one part of what this plugin does — package-aware format-on-save, a
brace-aware indentexpr, :Ballerina{Run,Test,Build} with quickfix
integration, and DAP debugging all ship alongside the LSP config, so
swapping in nvim-lspconfig for the LSP piece alone still leaves those out.
(If you already use nvim-lspconfig, it's not a conflict either way — see
LSP capabilities under Configuration.)
vim.lsp.config definition — hover, completion,
rename, code actions, diagnostics, the worksbal format can't format a single
file that belongs to a package, so the plugin formats the whole package
and reloads affected buffers)indentexpr (cindent misreads
io:println(...) as a C jump label):Ballerina{Run,Test,Build,Format} commands, with compiler
diagnostics landing in the quickfix list:checkhealth ballerinabrew install ballerina (bundles its own JVM, no separate JDK needed).
Developed against Swan Lake 2201.13.x.bal on your PATH. If it isn't (common when GUI Neovim is launched
outside a login shell), the plugin also checks $BALLERINA_HOME and the
official installer locations — see
Setting the Ballerina distribution.{
"redpierrot/ballerina.nvim",
ft = "ballerina",
opts = {},
}
Published on LuaRocks — also installable with rocks.nvim:
:Rocks install ballerina.nvim
Plug 'redpierrot/ballerina.nvim'
-- after plug#end():
require("ballerina").setup({})
Or with Neovim's built-in package support (:h packages):
git clone https://github.com/redpierrot/ballerina.nvim \
~/.local/share/nvim/site/pack/plugins/start/ballerina.nvim
Calling setup() is optional — the plugin works with its defaults as soon
as a .bal file is opened. Call it only to override options.
Open a .bal file. You get indentation, syntax highlighting, and the
language server attaches automatically (first start takes a few seconds
while the JVM warms up — watch :checkhealth vim.lsp).
Neovim's default LSP mappings work out of the box: K hover, grn rename,
gra code action, grr references, gO document symbols, [d/]d
diagnostics.
| Command | Action |
|---|---|
:BallerinaFormat |
Format the file (or its enclosing package) now |
:BallerinaFormatToggle |
Toggle format-on-save for this buffer |
:BallerinaRun [args] |
bal run the package/script in a terminal split |
:BallerinaTest [args] |
bal test in a terminal split |
:BallerinaBuild [args] |
bal build in a terminal split |
Run/Test/Build accept arguments: everything before a literal -- is
a CLI option (before the target), everything after is a program argument
(after the target) — matching bal run [options] [target] [-- program-args]:
:BallerinaTest --tests fooTest
:BallerinaRun -- 8080 --verbose
Compiler diagnostics and bal test assertion failures both land in the
quickfix list — the quickfix window opens automatically once you close the
terminal split (it won't pop up over output you're still reading). Prefer
:make? :compiler ballerina sets the same makeprg/errorformat.
Saving a file runs bal format in the background. To turn that off for one
buffer, use :BallerinaFormatToggle (or set
vim.b.ballerina_disable_format = true); to turn it off everywhere, set
format_on_save = false.
Defaults:
require("ballerina").setup({
bal_cmd = nil, -- path to the `bal` binary; nil = auto-detect
bal_home = nil, -- path to a distribution root; nil = auto-detect
format_on_save = true,
indent = true,
lsp = {
enabled = true,
root_markers = { "Ballerina.toml" },
file_watch = true, -- see Troubleshooting if you hit a watcher crash
config = nil, -- extra vim.lsp.Config fields, e.g. capabilities
},
dap = {
enabled = true,
},
})
By default the plugin resolves bal from your PATH, then
$BALLERINA_HOME/bin/bal, then the official installer locations.
:checkhealth ballerina always shows which one it picked.
Override it with either:
bal_home — a distribution root (the $BALLERINA_HOME layout, with
bin/bal inside). Handy for pointing at a locally built distribution.bal_cmd — an exact binary path. Wins over bal_home if both are set.Globally, in every project:
require("ballerina").setup({
bal_home = "~/ballerina-lang/distribution/zip/jballerina-tools/build/"
.. "extracted-distributions/jballerina-tools-<version>",
})
Per-project — e.g. trying a local ballerina-lang build against one
repo without touching your global config — pick one:
.nvim.lua (Neovim's exrc, see :h exrc) that calls
setup() with a different bal_home. Requires vim.o.exrc = true and
trusting the file once (:h :trust).$BALLERINA_HOME set per-directory, e.g. via
direnv — already in the auto-detect chain, so it
works automatically as long as bal_home/bal_cmd aren't also set
globally (explicit config always wins over the env var).To pass completion capabilities from blink.cmp or nvim-cmp:
require("ballerina").setup({
lsp = {
config = {
capabilities = require("blink.cmp").get_lsp_capabilities(),
-- capabilities = require("cmp_nvim_lsp").default_capabilities(),
},
},
})
Or, since the server is a native vim.lsp.config definition, set it
directly without going through setup():
vim.lsp.config("ballerina", {
capabilities = require("blink.cmp").get_lsp_capabilities(),
})
nvim-lspconfig ships an
equivalent ballerina definition. That's fine: Neovim merges same-named
lsp/ definitions, and anything set via vim.lsp.config("ballerina", ...)
wins over both. You will not get two clients.
With nvim-dap installed,
opening a .bal file registers the Ballerina debug adapter (the same
bal start-debugger-adapter the VS Code extension uses) plus four
configurations:
bal test under the debuggerbal run --debug <port>Set a breakpoint and :lua require("dap").continue() (or your usual dap
keymaps). No launch.json needed. The first launch takes several seconds —
the adapter and the debuggee are JVM processes.
:checkhealth ballerina first — it verifies the Neovim version,
locates bal (and prints which one), and reports the LSP client state.bal missing from the
environment Neovim was launched in — see
Setting the Ballerina distribution.:checkhealth vim.lsp shows the client log if the server starts and then
crashes.[!WARNING] Neovim can crash on macOS when LSP file-watching hits a pathologically long build-cache path. This plugin scopes what it watches to work around it, but see the workaround below if it still happens to you.
Mentioning a path under a compiler/Gradle build cache (target/,
.gradle/, ...): a known Neovim limitation on macOS, where LSP workspace
file watching uses a single recursive fs_event over the whole project by
default. It has no way to exclude subdirectories at the OS level, so if a
build ever produces a pathologically long or invalid path there (observed
with JaCoCo code-coverage instrumentation in Gradle-wrapped builds), Neovim
asserts and crashes outright — before the change even reaches this
plugin's LSP client.
This plugin works around it by scoping what gets watched to Ballerina's
own package structure (Ballerina.toml, loose .bal files, modules/,
generated/) instead of the whole workspace folder, so a build cache is
never watched, recursively or otherwise — see lua/ballerina/lsp_watch.lua
and docs/proposals/scoped-lsp-file-watch.md for the mechanism.
Even the package root's own non-recursive watch isn't fully immune: under a
heavy write burst inside a build-cache directory (JaCoCo instrumenting
hundreds of classes during bal test --code-coverage is the observed
trigger), a stray event can still surface a pathologically long path from
deep inside target/, and core's vim._watch.watch asserts and crashes on
that unconditionally. This plugin's root watcher uses its own fs_event
wrapper instead of calling into core's, so that specific assert can no
longer bring Neovim down.
If you hit a crash anyway (e.g. a Ballerina LS version that registers watch
patterns this plugin doesn't recognize — it warns loudly when that
happens), set lsp.file_watch = false as a full opt-out. The tradeoff:
the server no longer auto-discovers files it didn't get through Neovim
(git checkouts/pulls, .bal source generated by bal openapi/grpc/etc.
run outside Neovim) — open or re-save the generated file, or :LspRestart,
to pick those up.
The official grammar (ballerina.YAML-tmLanguage) is a TextMate
grammar consumed by the VS Code extension, forked from the same scaffold
used for TypeScript's grammar. Most of its complexity is generic
disambiguation machinery (arrow functions vs. comparisons vs. generics)
that doesn't reflect anything Ballerina-specific and can't be expressed in
Vim's regex engine (no recursive patterns). Rather than attempt a
byte-for-byte port, this plugin takes the authoritative keyword/type lists
from the compiler's LexerTerminals.java (plus parser-level contextual
keywords like group and collect) and implements conventional
:syntax keyword/:syntax match/:syntax region rules around them — the
same level of coverage most language syntax files have.
bal testbal test adapter is planned, see RoadmapSee CONTRIBUTING.md. In short: make test, make lint,
make fmt — CI runs the same checks.