composer-ls is a language server for recipe group files. It runs beside your Elixir language server (ElixirLS, Next LS, Expert), handles only recipe references, and replaces nothing. It keeps your cookbooks loaded in one BEAM, so there is no mix startup on each save.

It gives the editor:

  • Diagnostics on open and on save: the same diagnostics as mix composer.cookbook.lint --file (see the lint guide), in the saved file and in the files that include its recipes. When you fix a typo in local:ash/base, the "has 1 problem" summary disappears from app.exs without opening it.
  • Hover on a reference: a step's metadata and docs, a recipe's shortdoc, flags and entries, what a hex: package (or an unqualified name that falls through to Hex) does, or an alias's target cookbook. A reference that doesn't resolve shows why.
  • Goto-definition: a step's source (also under deps/), a recipe's key in its group file, or an alias's entry in aliases: (or the top-level binding it names). hex: references link to HexDocs.
  • Completion of step ids, recipe ids and the recipe's alias prefixes in steps:, and of recipe ids in {:use, ", even while the buffer doesn't parse.
  • Quick fixes that replace a typo with lint's suggestion.

Install

composer-ls needs the gen_lsp package, an optional dependency of composer. Add it to the host project, next to composer:

{:composer, "~> 0.1", only: :dev},
{:gen_lsp, "~> 0.11.4", only: :dev}

then mix deps.get. Hosts without it pay nothing; there, mix composer.lsp says what to add and exits.

Start: the working directory is the host project

mix composer.lsp

starts the server on stdio. Your editor runs this for you.

Recipes refer to the cookbooks installed in a project, so the server needs one: the host project is the directory the editor starts the server in. Start your editor from the host project:

cd ~/src/my_app && nvim ~/.config/composer/recipes/auth.exs

There is no settings file or environment variable for this. A reference valid in one host may be invalid in another, and that is expected: the server names the host in a message at startup (composer-ls: host project my_app at /home/you/src/my_app) and at the foot of every hover.

Started outside a Mix project, the server exits at once with "must be run inside a Mix project".

Neovim

This uses vim.lsp.config and vim.lsp.enable, from Neovim 0.11:

vim.lsp.config("composer_ls", {
  cmd = { "mix", "composer.lsp" },
  -- Keep Mix's own output (compiling dependencies) off stdout.
  cmd_env = { MIX_QUIET = "1" },
  filetypes = { "elixir" },
  -- Attach to recipe files only: local recipes and a cookbook's recipes.
  -- The root is Neovim's working directory, the host project, so every
  -- recipe buffer shares one client per host.
  root_dir = function(bufnr, on_dir)
    local name = vim.api.nvim_buf_get_name(bufnr)
    if name:match("/composer/recipes/[^/]+%.exs$")
      or name:match("/lib/composer/[^/]+/recipes/[^/]+%.exs$")
    then
      on_dir(vim.fn.getcwd())
    end
  end,
})

vim.lsp.enable("composer_ls")

root_dir calls on_dir only for the two kinds of recipe file, so other .exs files don't start the server. Neovim reuses a client whose name and root match, so a local recipe and a cookbook's recipe opened in one session share one composer_ls client. :checkhealth vim.lsp lists it.

Neovim's LSP mappings apply: K hovers and gra lists the quick fixes. Map vim.lsp.buf.definition() (commonly to gd) for goto-definition. Completion arrives through your completion plugin or vim.lsp.completion.enable(). The HexDocs links of hex: references are LSP document links, for clients and plugins that show them.

Turn off nvim-lint for these buffers

If you followed the lint guide and run composer.cookbook.lint through nvim-lint on save, remove that autocmd: the server reports the same diagnostics, and both together show each problem twice.

Reload after editing step code

Recipe edits need nothing: group files are read again on every lint. Steps are compiled code, though, so after editing a step in a path-dep cookbook, run the composer.reload command. It recompiles the project and rediscovers the cookbooks, then reports "composer-ls: reloaded N cookbooks" (or the compile error):

vim.api.nvim_create_user_command("ComposerReload", function()
  for _, client in ipairs(vim.lsp.get_clients({ name = "composer_ls" })) do
    client:exec_cmd({ title = "Reload cookbooks", command = "composer.reload" })
  end
end, {})

The server compiles only at startup and on reload, never while idle. Restarting the server (:lsp restart in Neovim 0.12) does the same and more; restart after changing composer itself.

Troubleshooting

Nothing attaches. Check :checkhealth vim.lsp. The buffer's path must match one of the two root_dir patterns, and mix composer.lsp must start in the host project: run it in a terminal there. It should print nothing and wait for input; a message about gen_lsp means the dependency is missing.

The wrong project, or unknown cookbooks. The startup message and the hover footer name the host. Things that move Neovim's working directory (:cd, autochdir, project-root plugins) before the first recipe buffer opens give the server another project, or none.

The server died at startup, or garbage in the log. Only protocol messages may go to stdout. The server sends its own logging, and any output from steps, cookbooks or recipe group files, to stderr, which Neovim writes to its LSP log (:lua vim.cmd.tabnew(vim.lsp.log.get_filename())). Mix itself prints dependency compilation on stdout before the server starts; MIX_QUIET=1 (in cmd_env above) silences it.

Stale step metadata or docs. Run composer.reload, or restart the server. A reload while mix composer.recipe.run compiles in a terminal may contend for _build; wait for the run to finish.

Diagnostics differ from the buffer. Diagnostics come from the file on disk, on open and on save, as lint's do. Hover and completion follow the unsaved buffer.