Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

elixir-gaps.nvim

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, tagged Game? so a guess never reads as fact. Bare parameters (defp pay_step(game)), destructured call results ({:ok, placed} = Mansion.fetch(…)), with clauses, case branches, pipelines: all covered.
  • Create the function you just called<leader>cn / :ElixirNewDef on a call stubs def/defp where it belongs (its module's file, a new lib/<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 missing alias, and opens it in a new tab.
  • @spec from 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 @spec a 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 _var back, 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, mixClean and an llm* family that no Neovim client exposes: <leader>cx expands the macro under the cursor (or the selection) in a float — see what use, defstruct, with desugar to; "Convert to/from pipe" code actions; :ElixirLS restart|clean|clean-deps|expand|to-pipe|from-pipe; and llmTypeInfo feeds 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 see game: Game.t(): ElixirLS's own inference where it has one, the completion guesser's shape (marked ?) where it doesn't. <leader>ch / :ElixirHints toggles.
  • Navigate by symbol<leader>cI lists the implementations of the behaviour, protocol or callback under the cursor, <leader>cD the project modules that depend on the module under the cursor (tagged compile / runtime / exports), <leader>ck the aggregated docs of the symbol under the cursor in a markdown float; :ElixirLS def shows a definition's source. All on ElixirLS's llmImplementationFinder, llmModuleDependencies, llmDocsAggregator and llmDefinition.
  • 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.

Requirements

  • Neovim ≥ 0.11 (0.12 tested)
  • the tree-sitter elixir parser (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

Install

-- 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" } }
  ),
})

Configuration

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)
  },
})

How the completion guesser works

For var. it tries, in order, and stops at the first hit:

  1. an explicit binding in the buffer: %Mod{…} = var / var = %Mod{…}
  2. 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, one var = other hop — followed through the callee's @spec: the destructuring pattern is matched against the return type's union ({:ok, PlacedRoom.t()} | :errorPlacedRoom.t()), and the type resolved through Mod.t() / t() / %Mod{} / local @type aliases / plain %{…} map types
  3. naming: gameGame against 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("."))

Create definition

<leader>cn on Mod.fun(args), fun(args) or &Mod.fun/2:

  • Mod is resolved through the buffer's aliases; an unaliased single segment that names a project module by its last segment (EffectManor.Effect) is taken to mean that module and the alias is inserted — the usual cause of "Function Effect.apply_action/2 does not exist"
  • if fun/arity already exists you're taken to it, nothing is duplicated
  • otherwise a stub is appended at the end of that module (a new lib/<path>.ex with a defmodule when there is no file), or for a local call a defp after the last clause of the current function, or at the module's end when the call sits outside any def
  • argument names come from the call site (variables, game.mansionmansion, %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

Infer @spec

<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:

  1. the pattern: %__MODULE__{} = gamet(), %Mod{}Mod.t(), literal atoms / numbers / strings / lists / maps, tuples sized ({term(), term()}), nil, booleans, _xterm()
  2. guards: is_atomis_pid, x in [:a, :b], is_struct(x, Mod)
  3. call sites in the buffer with the same arity (fun(…), and for public functions Alias.fun(…); x |> fun(…) feeds the first argument) — each argument expression typed like the completion guesser does: a struct-bound variable, game.config.shop_offers followed through the @types, a literal, a call's @spec return. Two or more distinct atoms become a union (:shop | :armory), more than six atom().

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.

Quick fixes

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 commands

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.

Inlay type hints

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.

Navigate

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

./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, init

The 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.

Status

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.

License

MIT

About

Neovim tooling for the moments ElixirLS goes quiet: struct-field completion guesses, create-the-function-you-just-called, quick-fix code actions for compiler diagnostics, snippets

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages