Neovim tooling for the moments ElixirLS (or Expert) goes quiet: you are typing code that does not exist yet, or code whose types the server can't see.
var.completion when the server has no type — a nvim-cmp source that guesses the struct or map behind a variable and offers its fields, taggedGame?so a guess never reads as fact. Bare parameters (defp pay_step(game)), destructured call results ({:ok, placed} = Mansion.fetch(…)),withclauses,casebranches, pipelines: all covered.- Create the function you just called —
<leader>cn/:ElixirNewDefon a call stubsdef/defpwhere it belongs (its module's file, a newlib/<path>.ex, or right after the current function), names the arguments from the call site, leaves tabstops for what it can't name, adds the missingalias, and opens it in a new tab. @specfrom usage —<leader>cs/:ElixirInferSpec(or the "Infer @spec for fun/n" code action on a def line) reads the function's clause heads, guards, call sites and clause tails and inserts the@speca reader would have guessed —@spec restock(t(), [Offer.t()], integer()) :: t()— above the first clause, under the@doc. ElixirLS completes and Dialyzer infers only where specs exist, and private helpers never get a suggestion.- Code actions for the compiler's warnings — an in-process language
server that turns diagnostics into quick fixes: create/alias the undefined
function, remove an unused alias/import/require, add the missing
@impl, rename a used_varback, underscore every unused variable on the line at once, delete an unused private function, infer a@spec. - ElixirLS's hidden commands, surfaced — the server advertises
expandMacro,manipulatePipes,restart,mixCleanand anllm*family that no Neovim client exposes:<leader>cxexpands the macro under the cursor (or the selection) in a float — see whatuse,defstruct,withdesugar to; "Convert to/from pipe" code actions;:ElixirLS restart|clean|clean-deps|expand|to-pipe|from-pipe; andllmTypeInfofeeds the completion guesser the@type t/@specs of compiled modules without spawning the BEAM. - Inlay type hints — Elixir has none; the plugin's in-process server
declares
inlayHintProvider, so after every binding you seegame: Game.t(): ElixirLS's own inference where it has one, the completion guesser's shape (marked?) where it doesn't.<leader>ch/:ElixirHintstoggles. - Navigate by symbol —
<leader>cIlists the implementations of the behaviour, protocol or callback under the cursor,<leader>cDthe project modules that depend on the module under the cursor (tagged compile / runtime / exports),<leader>ckthe aggregated docs of the symbol under the cursor in a markdown float;:ElixirLS defshows a definition's source. All on ElixirLS'sllmImplementationFinder,llmModuleDependencies,llmDocsAggregatorandllmDefinition. - Snippets (LuaSnip):
defst/defpst— a def whose first argument is%__MODULE__{} = var, the idiom that gives ElixirLS real completion —with/withe,structt(defstruct +@type t),spec,impl.
Everything is plain Lua on top of tree-sitter and vim.lsp; no background
processes except an optional, cached elixir -e for stdlib structs.
- Neovim ≥ 0.11 (0.12 tested)
- the tree-sitter
elixirparser (e.g. via nvim-treesitter) - ElixirLS or Expert attached for the LSP-assisted parts (hover specs, rename);
ElixirLS ≥ 0.31 for its commands (expand macro, to/from pipe, restart,
mix clean,llmTypeInfo, inlay hints from its inference, implementations / dependents / docs / definition source): the wire contract is the one v0.31.1 speaks. An older server that lacks a command says so in one line — nothing else depends on it - optional: nvim-cmp for the completion source, LuaSnip for snippets and argument tabstops
-- lazy.nvim
{
"JustSomeContent/elixir-gaps.nvim",
ft = { "elixir", "eelixir", "heex" },
opts = {}, -- see Configuration
}Then hook the completion source into nvim-cmp — in its own fallback group, so the guesses only show when the language server returned nothing:
local cmp = require("cmp")
cmp.register_source("elixir_struct", require("elixir_gaps").cmp_source())
cmp.setup({
sources = cmp.config.sources(
{ { name = "nvim_lsp" }, { name = "luasnip" } },
{ { name = "elixir_struct" } },
{ { name = "buffer" } }
),
})Defaults:
require("elixir_gaps").setup({
filetypes = { "elixir", "eelixir", "heex" },
-- buffer-local normal-mode maps in those filetypes; any entry false for none
keymaps = {
create_definition = "<leader>cn",
infer_spec = "<leader>cs",
expand_macro = "<leader>cx",
inlay_hints = "<leader>ch",
implementations = "<leader>cI",
dependents = "<leader>cD",
docs = "<leader>ck",
},
command = "ElixirNewDef", -- false for none
infer_spec_command = "ElixirInferSpec", -- false for none
inlay_hints_command = "ElixirHints", -- toggle; false for none
-- :ElixirLS restart|clean|clean-deps|expand|to-pipe|from-pipe|impls|deps|dependents|docs|def; false for none
elixirls_command = "ElixirLS",
-- false leaves the provider off: no hints, no <leader>ch / :ElixirHints
inlay_hints = { enabled = true, guesses = true, max_label = 40 },
navigate = { open = "edit" }, -- how a picked implementation / module opens: edit | tabedit | split | vsplit
-- in-process code-action server (quick fixes + refactors); it also carries
-- the inlay hint provider, so false turns the hints off with it
quickfix = true,
snippets = true, -- load snippets/luasnip/ (needs LuaSnip)
struct_source = {
vm = true, -- ask the BEAM (`elixir -e`) about structs with no source on disk
hover = true, -- ask the language server's hover for @specs of compiled callees
lsp = true, -- ask ElixirLS's llmTypeInfo for compiled modules' @type t / @specs (before the BEAM)
},
})For var. it tries, in order, and stops at the first hit:
- an explicit binding in the buffer:
%Mod{…} = var/var = %Mod{…} - the call that bound it —
var = Mod.fun(…),{:ok, var} = …,{:ok, var} <- …,case Mod.fun(…) do … {:ok, var} ->, the last stage of a pipeline, local calls, onevar = otherhop — followed through the callee's@spec: the destructuring pattern is matched against the return type's union ({:ok, PlacedRoom.t()} | :error→PlacedRoom.t()), and the type resolved throughMod.t()/t()/%Mod{}/ local@typealiases / plain%{…}map types - naming:
game→Gameagainst the enclosing module, aliases, modules in the file,lib/**/game.ex
Facts come from project and dependency source (tree-sitter: defstruct,
@type, @spec), from the language server's hover at the call site for
compiled callees, from ElixirLS's llmTypeInfo command for compiled
modules' struct fields, @type t and @specs (struct_source.lsp = false
to opt out), and — when no server can answer, none attached or the module
unknown to it — from the BEAM (elixir -e, ~0.2 s, cached per module,
asynchronous) for structs such as DateTime or URI. Each item documents why
it was guessed:
:lua =require("elixir_gaps").resolve(0, "placed", vim.fn.line("."))
<leader>cn on Mod.fun(args), fun(args) or &Mod.fun/2:
Modis resolved through the buffer's aliases; an unaliased single segment that names a project module by its last segment (Effect→Manor.Effect) is taken to mean that module and thealiasis inserted — the usual cause of "Function Effect.apply_action/2 does not exist"- if
fun/arityalready exists you're taken to it, nothing is duplicated - otherwise a stub is appended at the end of that module (a new
lib/<path>.exwith adefmodulewhen there is no file), or for a local call adefpafter the last clause of the current function, or at the module's end when the call sits outside anydef - argument names come from the call site (variables,
game.mansion→mansion,%Game{}→game), or from another call site passing a variable there; the rest become LuaSnip tabstops — you land on the first one and<Tab>through them into the body - the file opens in a new tab (or the tab already showing it). Nothing is
written to disk: review, then
:w
<leader>cs / :ElixirInferSpec anywhere inside a def/defp (or on the @doc
above it) inserts @spec name(args) :: ret directly above the first clause;
the Infer @spec for fun/n code action does the same from a def line. A
function that already has a spec for that name/arity is left alone.
Argument types, per position, union across clauses — the first rule that yields something wins per clause:
- the pattern:
%__MODULE__{} = game→t(),%Mod{}→Mod.t(), literal atoms / numbers / strings / lists / maps, tuples sized ({term(), term()}),nil, booleans,_x→term() - guards:
is_atom…is_pid,x in [:a, :b],is_struct(x, Mod) - call sites in the buffer with the same arity (
fun(…), and for public functionsAlias.fun(…);x |> fun(…)feeds the first argument) — each argument expression typed like the completion guesser does: a struct-bound variable,game.config.shop_offersfollowed through the@types, a literal, a call's@specreturn. Two or more distinct atoms become a union (:shop | :armory), more than sixatom().
A position nobody says anything about is term().
The return type is the union of every clause's tail expressions, descending
into case / cond / if / unless / with / try / receive branches:
{:ok, offer} is typed element by element, %{game | …} is the type of
game, a pipeline is its last call's @spec return, a with without else
adds the members of each <- source that its pattern cannot match
(:error for {:ok, x} <- fetch(…)), a clause that only raises is
no_return(), an unknown tail stays term() so the gap is visible. Other
modules are written through the buffer's aliases; the module's own type is
t().
Heuristic like everything here: review the line, then keep it. Dialyzer and ElixirLS do the rest once it exists.
Neovim has no hook for client-side code actions, so quickfix.lua is a tiny
language server living in the Neovim process (vim.lsp.config with a Lua
cmd). It implements textDocument/codeAction (plus the commands behind its
actions) and textDocument/inlayHint (see Inlay type hints): for code actions
it reads the buffer's diagnostics on the requested lines — ElixirLS, Expert,
the compiler — and offers, next to the server's own actions:
| diagnostic | action |
|---|---|
undefined function fun/n, Mod.fun/n is undefined, Function Mod.fun/n does not exist, module Mod is not available |
Create / alias via ElixirNewDef |
unused alias/import/require X |
Remove it (also out of A.{B, C}) |
module attribute @impl was not set for function … |
Add @impl Behaviour |
the underscored variable "_x" is used after being set |
Rename _x to x everywhere (LSP rename) |
variable "a" is unused (several on one line) |
Prefix them all with _ |
function fun/n is unused |
Delete its clauses |
(no diagnostic) a def / defp line without a @spec |
Infer @spec for fun/n (refactor.rewrite) |
| (no diagnostic) a line with a parenthesised call | Convert to pipe (ElixirLS) (refactor.rewrite; needs an ElixirLS client) |
(no diagnostic) a line with a |> |
Convert from pipe (ElixirLS) (refactor.rewrite; needs an ElixirLS client) |
The refactors read the buffer with the elixir grammar, so they appear in
elixir buffers only — the server also attaches to heex/eex templates for
their diagnostics, where only the quick fixes show. Over a visual range each
pipeable line gets its own action, titled … (line N); quick fixes with the
same title (one warning from two sources) are listed once.
Tip: Neovim 0.12's vim.lsp.buf.code_action() passes a server only the
diagnostics under the cursor. With the cursor next to — not on — an unused
variable, ElixirLS's own "Rename to _a" never shows. A mapping that requests
over the whole line's diagnostics fixes that:
vim.keymap.set({ "n", "v" }, "<leader>ca", function()
if vim.api.nvim_get_mode().mode == "n" then
local lnum = vim.api.nvim_win_get_cursor(0)[1]
local s, e = math.huge, -1
for _, d in ipairs(vim.diagnostic.get(0, { lnum = lnum - 1 })) do
s, e = math.min(s, d.col), math.max(e, d.end_col or d.col)
end
if e >= 0 then
return vim.lsp.buf.code_action({ range = { start = { lnum, s }, ["end"] = { lnum, e } } })
end
end
vim.lsp.buf.code_action()
end, { desc = "Code actions (this line's diagnostics)" })ElixirLS advertises commands beyond the LSP standard (read them from
:lua =vim.lsp.get_clients({name="elixirls"})[1].server_capabilities.executeCommandProvider.commands).
The plugin wires the useful ones:
| what | how |
|---|---|
| expand the macro under the cursor / the visual selection | <leader>cx, :ElixirLS expand (:'<,'>ElixirLS expand for a range) — a float with expand once / expand / expand all; q closes |
| convert a call to a pipeline, or a pipeline back to a call | Convert to pipe (ElixirLS) / Convert from pipe (ElixirLS) code actions on the line; :ElixirLS to-pipe / from-pipe — ElixirLS applies the edit |
restart the server, clean the build (mix clean, optionally deps) |
:ElixirLS restart, :ElixirLS clean, :ElixirLS clean-deps — for the days diagnostics go stale |
| types and specs of compiled modules | llmTypeInfo, used by the completion guesser (struct_source.lsp) |
| types of the variables in scope | llmEnvironment, used by the inlay hints |
| implementations / dependents / docs / definition of a symbol | <leader>cI, <leader>cD, <leader>ck, :ElixirLS impls|deps|dependents|docs|def — see Navigate |
Everything here degrades quietly: without an ElixirLS client (or with an older ElixirLS lacking a command) you get a one-line notice, nothing breaks. The wire contract follows ElixirLS ≥ 0.31 (checked against v0.31.1, see Requirements); command ids are resolved from the server's capabilities, so the per-instance suffix ElixirLS appends never needs configuring.
Elixir has no inlay hints — neither ElixirLS nor Expert implements them — but
the plugin already knows types: the quickfix server declares
inlayHintProvider, and after every binding you see game: Game.t(). Sites
are found with tree-sitter: the left side of = (variables nested in tuples,
lists, maps and struct patterns, [h | t], "pre" <> rest, bitstrings), the
left side of <- in with/for, the patterns of case/fn/receive
clauses and def/defp/defmacro parameters (%Mod{} = var included) — one
hint per variable per binding, never on a use, never on map or keyword keys,
_x or pinned ^x, and never for a literal binding (n = 1 says it all).
Two sources, per variable: ElixirLS's own inference (its llmEnvironment
command, asked once per function at its last statement) when the rendered type
says something — a struct (%Mod{} → Mod.t()), map, tuple, list, atom,
module, number or binary(), never any()/term()/nil/another variable/an
unevaluated expression — with tooltip "ElixirLS inference"; otherwise the
completion guesser's shape, marked with a trailing ? and its reason as
tooltip. Types are written with the buffer's aliases and t() for the own
module, cut at max_label characters. <leader>ch / :ElixirHints toggles
them per buffer (so does Neovim's own vim.lsp.inlay_hint.enable);
inlay_hints = { enabled = true, guesses = true, max_label = 40 } configures
(enabled = false leaves them off until you toggle; guesses = false shows
only ElixirLS's types). inlay_hints = false leaves the provider off: the
quickfix server attaches without inlayHintProvider, so nothing — not even
vim.lsp.inlay_hint.enable — shows a hint, and the toggle map / command are
not defined; quickfix = false turns them off too, since that server is the
provider.
Budget: Neovim re-requests hints after every change, so the handler never
blocks — it replies within ~1.5 s with what it has and late answers refresh the
buffer; the server is asked only for functions visible in a window, an older
answer is shown while the fresh one is on its way; the guesser is consulted
only where it can answer, its answers remembered per line until the file is
written or hints are toggled. Heuristic like everything here — the ? is the
tell.
ElixirLS keys four of its llm* commands by symbol string rather than by
cursor position, which makes them answer where the standard requests go quiet:
textDocument/implementation says nothing on the behaviour's own @callback
line, nothing in the LSP lists a module's dependents, and hover shows one
function, not a module's surface. The plugin reads the symbol from the cursor
with tree-sitter — an alias anywhere (@behaviour X, use X, alias X.{Y},
%X{}, defmodule X, the Mod of Mod.fun) is a module, expanded through
the buffer's aliases; Mod.fun(args) / x |> Mod.fun() / &Mod.fun/2 /
:lists.map(l) is Mod.fun/arity; a local call or a def head is
Enclosing.fun/arity; a @callback line is the callback — and asks the
server.
| what | how |
|---|---|
| implementations of a behaviour / protocol / callback | <leader>cI, :ElixirLS impls [symbol] — on a @callback, on a callback def in an implementing module (the behaviour is read from @impl Mod or the module's single @behaviour), on a call to the callback, or inside a behaviour module: a vim.ui.select picker of Module lib/path.ex:line (one hit jumps at once); :ElixirLS! impls fills the quickfix list |
| modules that depend on this one / that it depends on | <leader>cD, :ElixirLS dependents [Mod] / :ElixirLS deps [Mod] — tagged [compile, runtime, exports] as the compile tracer recorded them; selecting opens the module's file; ! → quickfix list; require("elixir_gaps.navigate").dependents({ transitive = true }) for the transitive compile graph |
| docs of the symbol under the cursor | <leader>ck, :ElixirLS docs [symbol] — a markdown float (q/<Esc> close): a module's moduledoc plus its functions / types / callbacks / behaviours, a function's doc and @specs, a callback's spec, a type's doc; when nothing comes back you get vim.lsp.buf.hover() |
| the source of a definition | :ElixirLS def [symbol] — attributes, comments and the definition through its end as the server reads them from disk, titled file:line; <CR> jumps there |
Pickers use vim.ui.select (telescope / snacks / fzf-lua take over when
installed); selections open with navigate.open (edit | tabedit |
split | vsplit). Limits inherited from the server: implementations are
found among modules compiled and loaded by the language server (unsaved
buffers and .exs files are invisible — vim.lsp.buf.implementation() stays
the cursor-based, buffer-aware path, and the only one that follows
defdelegate), a callback needs its arity, dependencies are the project's own
callers as of the last build, and docs/definitions read the compiled module /
the file on disk.
./tests/run.sh # nvim --headless -u tests/minimal_init.lua -l tests/run.lua
SPEC=quickfix ./tests/run.sh # also: create_definition, struct_source, infer_spec, elixirls, inlay, navigate, initThe suite runs against a fixture project under tests/fixtures/; it borrows
nvim-cmp and LuaSnip from $NVIM_LAZY_DIR (default: lazy.nvim's data dir)
when present and skips what needs them otherwise.
Heuristics, deliberately: everything here guesses from source and says so. The message patterns follow Elixir ≥ 1.15 / ElixirLS 0.31. Issues with examples of code it guesses wrong are the most useful kind.
MIT