Lint and editor diagnostics

Copy Markdown View Source

mix composer.cookbook.lint checks a cookbook, or your local recipes, and reports every problem as a diagnostic: a severity, a stable code, a file and a range in it. Editors, CI and scripts read the same diagnostics in the format each understands.

mix composer.cookbook.lint                     # this project's cookbook
mix composer.cookbook.lint --local             # your local recipes
mix composer.cookbook.lint --file PATH         # one recipe group file
mix composer.cookbook.lint --format compact    # text | compact | json | github

Each problem is reported where it is written: a bad reference at its string, a broken {:use, ...} at its target, an alias at its name, a missing key at the recipe's name. A recipe with three typos shows three diagnostics. A problem in a recipe that others include is reported once, in the recipe that has it.

Formats

text (the default) prints full messages, each after a clickable path:line:col: location; warnings first, then the errors, which make the task fail.

compact prints one line per diagnostic, for editors:

/home/me/.config/composer/recipes/auth.exs:15:8: error: In recipe local:auth/passkeys: no step matches "credoo". Did you mean: core:setup/credo? ... [typo]
/home/me/.config/composer/recipes/auth.exs:13:15: warning: local:auth/magic: alias lab (= composer_lab) is never used. Remove it, or use it. [unused_alias]

That is file:line:col: severity: message [code], the shape of GCC and of Credo's flycheck and oneline formats (mix credo --format flycheck), so an editor recipe written for mix credo adapts by changing the command. Paths are always absolute; the message is folded onto one line, dropping its indented examples. A diagnostic with no location is printed at :1:1 of the file most related to it, or of the cookbook's manifest.exs (for local, the first group file), so no line is ever unparseable.

json prints an array of diagnostics:

[
  {
    "severity": "error",
    "code": "typo",
    "file": "/home/me/.config/composer/recipes/auth.exs",
    "line": 15, "column": 8, "end_line": 15, "end_column": 16,
    "message": "In recipe local:auth/passkeys: no step matches \"credoo\".\n\n...",
    "suggestions": ["core:setup/credo"],
    "related": []
  }
]

related lists the other places a problem involves (file, the range, and a message): the other install of a conflicting_install, each include of a cycle, the first key of a duplicate_recipe, and the problems an include_problems summary counts.

github prints GitHub Actions workflow commands, which show as inline annotations on pull requests:

- run: mix composer.cookbook.lint --format github

Exit status and stdout

The task exits non-zero when there is an error, in every format; warnings alone exit zero. In every format but text, stdout holds nothing but the diagnostics: the project is compiled with a quiet Mix shell, and errors end the task with exit status 1 instead of a raise. Mix compiles dependencies that need it before the task can start; set MIX_QUIET=1 to silence that too (or run mix compile once beforehand).

Positions

Lines and columns are 1-based. Columns count characters as Elixir's tokenizer does (grapheme clusters), not bytes or UTF-16 code units. A located diagnostic always has an end (end_line, end_column, exclusive) as well as a start.

When the exact literal can't be found (a reference built at run time, such as "lab:" <> name), the position falls back to the recipe's steps: key, then its name, then line 1. A slightly vague position beats none.

One file: --file

Editors lint the file you just saved:

mix composer.cookbook.lint --format compact --file ~/.config/composer/recipes/auth.exs

--file takes one recipe group file and finds its cookbook from the path: a file under your local config directory's recipes/ is local; a file under a cookbook's lib/composer/<name>/recipes/ is that cookbook's. Every cookbook is still loaded, since includes cross files, but only that file's diagnostics are printed, and lint exits zero when it has none, whatever other files hold.

A {:use, ...} of a recipe that has problems of its own gets one summary diagnostic, include_problems ("local:ash/base has 2 problems."), with those problems as related locations. Otherwise a broken include would look fine from the including file.

The host project is your editor's working directory

Lint runs inside a Mix project, which provides the installed cookbooks your recipes refer to. From an editor, that project is the editor's working directory: start the editor from the host project,

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

and every integration below runs mix there. The file is passed by its absolute path, local is found from it, and compact output uses absolute paths, so diagnostics land on the right buffer even though the file lives outside the project.

Don't set a cwd in the editor configs. Things that move the working directory (:cd, autochdir, project-root plugins) break this: lint then fails with "must be run inside a Mix project", or checks against another project's cookbooks.

Editors

For warm diagnostics with no mix startup on save, plus hover, goto-definition, completion and quick fixes, use the language server instead: see the Composer LSP guide. The setups below run lint once per save.

Vim and Neovim: :make and the quickfix list

No plugin needed:

" e.g. in an autocmd for */composer/recipes/*.exs
setlocal makeprg=mix\ composer.cookbook.lint\ --format\ compact\ --file\ %:p
setlocal errorformat=%f:%l:%c:\ %trror:\ %m,%f:%l:%c:\ %tarning:\ %m

Then :make and :copen.

Neovim: nvim-lint, on save

local lint = require("lint")

lint.linters.composer = {
  cmd = "mix",
  args = { "composer.cookbook.lint", "--format", "compact", "--file" },
  stdin = false,
  append_fname = true, -- appends the buffer's full path
  stream = "stdout",
  ignore_exitcode = true, -- errors exit 1
  parser = require("lint.parser").from_errorformat(
    "%f:%l:%c: %trror: %m,%f:%l:%c: %tarning: %m",
    { source = "composer" }
  ),
}

vim.api.nvim_create_autocmd("BufWritePost", {
  pattern = { "*/composer/recipes/*.exs" },
  callback = function() lint.try_lint("composer") end,
})

nvim-lint runs the linter in Neovim's working directory, the host project. Avoid its env field for MIX_QUIET: it replaces the whole environment. Lines that don't match the errorformat (such as dependency compile output on a first run) are ignored.

VS Code: a task with a problem matcher

Open the host project as the workspace, and add to .vscode/tasks.json:

{
  "version": "2.0.0",
  "tasks": [{
    "label": "composer lint",
    "type": "shell",
    "command": "mix composer.cookbook.lint --format compact --file ${file}",
    "problemMatcher": {
      "owner": "composer",
      "fileLocation": "absolute",
      "pattern": {
        "regexp": "^(.*):(\\d+):(\\d+): (error|warning): (.*)$",
        "file": 1, "line": 2, "column": 3, "severity": 4, "message": 5
      }
    }
  }]
}

Diagnostics land in the Problems panel and as squiggles. Run the task on save with an extension such as "Trigger Task on Save".

Helix, Zed and others: efm-langserver

efm-langserver turns a command and an errorformat into a language server:

languages:
  elixir:
    - lint-command: "mix composer.cookbook.lint --format compact --file ${INPUT}"
      lint-stdin: false
      lint-ignore-exit-code: true
      lint-formats:
        - "%f:%l:%c: %trror: %m"
        - "%f:%l:%c: %tarning: %m"

Diagnostic codes

The code is the reason's tag. Codes are stable: editor configs and scripts may match on them.

CodeProblem
typoan unqualified name close to a step id (suggestions holds the candidates)
missing_stepno step matches a reference
missing_recipeno recipe matches a {:use, ...}
ambiguousan unqualified name matches in several cookbooks
unknown_cookbooka reference names a cookbook that is not installed
unknown_alias_targeta cookbook alias names a cookbook that is not installed
via_aliasa problem with a reference written through an alias
invalid_refa malformed reference string
local_stepa reference to a step in local, which holds recipes only
dep_opts_on_steppackage options on a reference that names a step
conflicting_installone package installed with different options
cyclerecipes that include each other
include_problems(--file) an included recipe has problems
invalid_entry, legacy_forma malformed or old-style step entry
unknown_entry_option, bad_entry_option, hex_only_optiona bad entry option
multiple_sources, source_conflict, git_option_without_git, multiple_git_refs, repo_sourceinconsistent hex: sources
recipe_missing_key, unknown_recipe_key, bad_recipe_value, recipe_shapea malformed recipe
multiline_shortdoca shortdoc on more than one line
undeclared_flagan if: flag the recipe does not declare
invalid_alias, reserved_alias, duplicate_alias, alias_atom_target, invalid_alias_target, alias_shadows_cookbooka bad cookbook alias
unused_alias (warning)an alias none of the recipe's references use
inconsistent_alias (warning)one alias naming different cookbooks in one group
duplicate_recipe, invalid_recipe_name, group_recipe_clashrecipe names
group_parse, recipe_eval, group_shape, group_reada group file that does not parse, raises, or is not a keyword list
invalid_group_name, recipes_subdir, empty_group (warning)group files
local_recipes_only, local_unrecognized, legacy_recipes_direntries in the local config directory
step_missing_meta, missing_step_test, requires_unresolveda step's metadata, test and requires
manifest_missing, manifest_stalethe cookbook's manifest
step_recipe_clash (warning)a step and a recipe share an id
legacy_templates_dir, orphaned_templates, unprefixed_templates, not_a_step, missing_templatetemplates and files under steps/
unused_template (warning)a template no template/1 call refers to

Step problems are placed in the step's source file: at its use Composer.Step, or at the template/1 path or requires reference in question.

From Elixir

Composer.Lint is a set of plain functions over discovered cookbooks; it prints nothing and returns %{diagnostics: [Composer.Lint.Diagnostic.t()]} (run/3, local/2), or per-file results for a set of group files (files/3). Lint.dependents/2 lists the recipes that include a recipe, directly or not, and Composer.Recipe.Locator indexes a group file's source (without evaluating it) for forward and cursor lookups.