Writing a Custom Credo Check in Elixir
Tags: elixir
Published: 2023-09-06 10:39:07

Static code analysis is an important tool to ensure a project meets the right code standards and quality. In Elixir, the most popular package for this is Credo. Not only does it offer dozens of pre-made checks, but it also allows you to create your own.

In this article, we will walk you through creating a Credo check. We will see how to write the code, enable the check in the Credo config, and make it nice to use.

Let’s start!

## Why Create Credo Checks?

There are many things you can create Credo checks for. Some examples are:

- To check a timestamp in migration files and see if it shows the correct date
- To forbid calling business logic from migration files
- To disallow referencing or aliasing `MyAppWeb` modules from `MyApp`
- To ensure some naming conventions, for example, forbidding the `is_` prefix for functions (prefer `active?` to `is_active`)

Now let's dive into a real-world example with an Elixir app.

## Getting Started with Credo for Elixir

Let's first add Credo to an Elixir project. We will start with a new empty mix project:

```sh
$ mix new my_app
```

Then we will `cd` into the newly created `my_app` and add Credo to the dependencies in `mix.exs`, so it looks like this:

```elixir
defp deps do
  [{:credo, "~> 1.7", only: [:dev, :test], runtime: false}]
end
```

After that, we can run `mix deps.get`, then run Credo with `mix credo`. The output is not very interesting — our project is empty, so it does not violate anything. Let's quickly change that. Open `lib/my_app.ex` and change it to this:

```elixir
defmodule MyApp do
  @moduledoc """
  Documentation for `MyApp`.
  """

  def test do
    x = 1; y = 2
  end
end
```

Now when we run `mix credo`, we get a nice error:

```sh
Checking 3 source files ...

  Code Readability
┃
┃ [R] ↗ Don't use ; to separate statements and expressions
┃       lib/my_app.ex:7:10 #(MyApp.test)

Please report incorrect results: https://github.com/rrrene/credo/issues

Analysis took 0.1 seconds (0.07s to load, 0.1s running 55 checks on 3 files)
3 mods/funs, found 1 code readability issue.
```

If we are unsure what that means, we can ask for details:

```sh
$ mix credo explain lib/my_app.ex:7

  MyApp
┃
┃   [R] Category: readability
┃    ↗  Priority: high
┃
┃       Don't use ; to separate statements and expressions
┃       lib/my_app.ex:7:10 (MyApp.test)
┃
┃    __ CODE IN QUESTION
┃
┃     5
┃     6   def test do
┃     7     x = 1; y = 2
┃                ^
┃     8   end
┃     9 end
┃
┃    __ WHY IT MATTERS
┃
┃       Don't use ; to separate statements and expressions.
┃       Statements and expressions should be separated by lines.
```

The output above is clipped. Run it yourself to read the whole explanation and to see it in color too!

## Anatomy of a Credo Check

Credo checks are divided into categories. The one above falls under "readability". We also have "consistency", "refactor", "design", and "warning". Each check also has its own priority (set to 'high' in the example above), name, and explanation.

The check is an Elixir module. Built-in checks are kept in the Credo repository.

The check in question is defined in [this file](https://github.com/rrrene/credo/blob/master/lib/credo/check/readability/semicolons.ex).

It starts with `use Credo.Check`, followed by quite a lot of passed configs. An identifier of a check is defined, its priority, tags, and a long string with an explanation — we saw this when we ran `mix credo explain`.

This is followed by a definition of the `run` function, taking a source file and some params as input, as the entry point for the check. It will run for every file where the check is enabled, passing the file as the first argument.

Other than that, the check defines a bunch of private functions. Three versions of `collect_issues` are responsible for finding lines with a semicolon token. And if found, the issues accumulator is updated with a relevant line and column number, as well as an appropriate message and trigger.

An important thing to note is `Credo.Code.to_tokens(source_file)` — it splits the source file contents into tokens and feeds those tokens to the `collect_issues` functions.

If we put `IO.inspect` there, we see that our file from the above listing looks like this (parsed as tokens):

```sh
[
  {:identifier, {1, 1, 'defmodule'}, :defmodule},
  {:alias, {1, 11, 'MyApp'}, :MyApp},
  {:do, {1, 17, nil}},
  {:eol, {1, 19, 1}},
  {:at_op, {2, 3, nil}, :@},
  {:identifier, {2, 4, 'moduledoc'}, :moduledoc},
  {:bin_heredoc, {2, 14, nil}, 2, ["Documentation for `MyApp`.\n"]},
  {:eol, {4, 6, 2}},
  {:identifier, {6, 3, 'def'}, :def},
  {:do_identifier, {6, 7, 'test'}, :test},
  {:do, {6, 12, nil}},
  {:eol, {6, 14, 1}},
  {:identifier, {7, 5, 'x'}, :x},
  {:match_op, {7, 7, nil}, :=},
  {:int, {7, 9, 1}, '1'},
  {:";", {7, 10, 0}},
  {:identifier, {7, 12, 'y'}, :y},
  {:match_op, {7, 14, nil}, :=},
  {:int, {7, 16, 2}, '2'},
  {:eol, {7, 17, 1}},
  {:end, {8, 3, nil}},
  {:eol, {8, 7, 1}},
  {:end, {9, 1, nil}},
  {:eol, {9, 4, 1}}
]
```

Indeed, we have a `{:";", {7, 10, 0}}` token, which is easy to match. It means that there is a semicolon in line 7, column 10 — which is exactly where the semicolon is in our code.

Armed with that knowledge, we can start writing our own check.

## Our Very Own Credo Check in Elixir

We will write a check forbidding us from doing "bare imports". What I mean by that is calls like `import Ecto.Changeset`.

Why should we do this, though? Well, when you import the entire `Ecto.Changeset` module, and then use specific functions from that module, it can be hard for future collaborators (or future you) to determine where those functions were first defined.

However, in Elixir, you can specify a list of particular functions to import. So this would be fine:

```elixir
import Ecto.Changeset, only: [cast: 4]
```

To start, we will add a "bare" import statement to our `MyApp` code. Remember to also add Ecto to the dependencies, otherwise the code will not compile.

```elixir
defmodule MyApp do
  @moduledoc false
  import Ecto.Changeset
end
```

Now we need the actual check too. Credo offers a mix task to generate a new check. It adds example content, which might sometimes be useful. For our purpose, it would be confusing to start with the example code and then remove most of it, so we will build our check from scratch.

If you want to use the generator in the future, here's how:

```sh
$ mix credo.gen.check lib/credo/precise_imports.ex
```

Instead, we will create the file skeleton ourselves. In the same location, let's start with this:

```elixir
defmodule Credo.Check.Readability.PreciseImports do
  @moduledoc false
  use Credo.Check,
    base_priority: :medium,
    explanations: [check: "Use :only with all imports"]

  @impl true
  def run(source_file, params) do
    []
  end
end
```

### Write Some Tests in Credo

To check if it works, we need to write some tests. Luckily for us, Credo makes this really easy.

```elixir
defmodule Credo.Check.Readability.PreciseImportsTest do
  use Credo.Test.Case

  @described_check Credo.Check.Readability.PreciseImports

  test "it should not raise issues" do
    """
    defmodule TestModule do
      import MyApp.Helpers, only: [hello: 0]
    end
    """
    |> to_source_file()
    |> run_check(@described_check)
    |> refute_issues()
  end

  test "it should report a violation" do
    """
    defmodule TestModule do
      import MyApp.Helpers
    end
    """
    |> to_source_file()
    |> run_check(@described_check)
    |> assert_issue()
  end
end
```

We also need to start the Credo application in our `test_helper.ex`:

```elixir
Credo.Application.start([], [])
```

Now we have one test passing and one failing because we have not implemented the check yet. How should we do that?

If we try to use `to_tokens`, it might be quite hard to catch the `import` without the `only` option. Tokens are always a flat list of, well, tokens. It's hard to pattern match to some cases, based on the option passed to the `import` call.

```sh
[
  {:identifier, {1, 1, 'defmodule'}, :defmodule},
  {:alias, {1, 11, 'TestModule'}, :TestModule},
  {:do, {1, 22, nil}},
  {:eol, {1, 24, 1}},
  {:identifier, {2, 3, 'import'}, :import},
  {:alias, {2, 10, 'Ecto'}, :MyApp},
  {:., {2, 15, nil}},
  {:alias, {2, 16, 'Changeset'}, :Helpers},
  {:eol, {2, 23, 1}},
  {:end, {3, 1, nil}},
  {:eol, {3, 4, 1}}
]
```

### ASTs in Elixir

We need a different approach. Fortunately, Elixir provides a "smarter" representation of the code as an AST (abstract syntax tree). We can get it by using `Credo.Code.ast`. Here is how the results look for our simple module:

```sh
> source = """
defmodule TestModule do
  import Ecto.Changeset
end
"""
> Credo.Code.ast(source)
{:ok,
 {:defmodule, [line: 1, column: 1],
  [
    {:__aliases__, [line: 1, column: 11], [:TestModule]},
    [
      do: {:import, [line: 2, column: 3],
       [{:__aliases__, [line: 2, column: 10], [:Ecto, :Changeset]}]}
    ]
  ]}}
```

And when we specify import with `only: [cast: 4]`:

```sh
> source = """
defmodule TestModule do
  import Ecto.Changeset, only: [cast: 4]
end
"""
> Credo.Code.ast(source)
{:ok,
 {:defmodule, [line: 1, column: 1],
  [
    {:__aliases__, [line: 1, column: 11], [:TestModule]},
    [
      do: {:import, [line: 2, column: 3],
       [
         {:__aliases__, [line: 2, column: 10], [:Ecto, :Changeset]},
         [only: [cast: 4]]
       ]}
    ]
  ]}}
```

This might look quite difficult to understand. Fortunately, Credo offers a `prewalk` function to make our job easier.

### The `prewalk` Function

Let's change our check to use the `prewalk` function:

```elixir
defmodule Credo.Check.Readability.PreciseImports do
  @moduledoc false
  use Credo.Check,
    base_priority: :normal,
    explanations: [check: "Use :only with all imports"]

  @impl true
  def run(source_file, params) do
    source_file
    |> Credo.Code.prewalk(&traverse(&1, &2, IssueMeta.for(source_file, params)))
  end

  defp traverse(ast, issues, issue_meta), do: {ast, add_issue(issues, issue(ast, issue_meta))}

  defp add_issue(issues, nil), do: issues
  defp add_issue(issues, issue), do: [issue | issues]

  defp issue({:import, meta, [{:__aliases__, _, _}]}, issue_meta) do
    issue_for(issue_meta, meta[:line])
  end

  defp issue({:import, meta, [{:__aliases__, _, _}, opts]}, issue_meta) do
    if Keyword.has_key?(opts, :only), do: nil, else: issue_for(issue_meta, meta[:line])
  end

  defp issue(_, _), do: nil

  defp issue_for(issue_meta, line_no) do
    format_issue(
      issue_meta,
      message: "Use :only with import statements",
      line_no: line_no
    )
  end
end
```

Let's break this down.

```elixir
def run(source_file, params) do
  source_file
  |> Credo.Code.prewalk(&traverse(&1, &2, IssueMeta.for(source_file, params)))
end
```

We take the `source_file` here and feed it to the `Credo.Code.prewalk` function. This accepts a function that is called for every node of the AST we traverse. If we were to log what arguments are passed by the `prewalk` function, the second one is an accumulator where we should store a list of offences detected by our check (starting with an empty list).

As for the first argument, it's the current node of the AST. In the first run, it will be the whole tree, and in the subsequent calls, it will pass the inner leaves.

The first run:

```sh
{:defmodule, [line: 1, column: 1],
 [
   {:__aliases__, [line: 1, column: 11], [:TestModule]},
   [
     do: {:import, [line: 2, column: 3],
      [
        {:__aliases__, [line: 2, column: 10], [:MyApp, :Helpers]},
        [only: [hello: 0]]
      ]}
   ]
 ]}
```

Second:

```sh
{:__aliases__, [line: 1, column: 11], [:TestModule]}
```

In the third step, it's just the name of the module:

```sh
:TestModule
```

There's nothing here to go deeper into, so we go to the next "sibling" node:

```sh
[
  do: {:import, [line: 2, column: 3],
   [
     {:__aliases__, [line: 2, column: 10], [:MyApp, :Helpers]},
     [only: [hello: 0]]
   ]}
]
```

It will go down deeper, but at some point before it finishes, the argument will be very useful for us. It will look like this:

```sh
{:import, [line: 2, column: 3],
 [{:__aliases__, [line: 2, column: 10], [:MyApp, :Helpers]}, [only: [hello: 0]]]}
```

Before we move to the `traverse` function, there is also an `IssueMeta.for(source_file, params)` call. In fact, this is
[`Credo.IssueMeta.for/2`](https://hexdocs.pm/credo/Credo.IssueMeta.html#for/2), but `use Credo.Check` creates an alias for it. The function returns a metadata structure, which will help Credo identify in which file the issue is found. We don't need to know more about it, just use it: pass it to `traverse` and then to `issue`.

```elixir
defp traverse(ast, issues, issue_meta), do: {ast, add_issue(issues, issue(ast, issue_meta))}
```

### The `traverse` Function

As we know from before, `traverse` accepts an AST and accumulator of found issues (initially empty). The third argument is the `issue_meta` required by Credo.

Implementing this function is simple: it returns the same AST node (so `prewalk` can take it and traverse it further) and changes the accumulator (or not) using the `add_issue` function.

```elixir
defp add_issue(issues, nil), do: issues
defp add_issue(issues, issue), do: [issue | issues]
```

So, if the "current issue" is a `nil`, just return the list of issues. But if it's something else, prepend it to the list.

### The `issue` Function

Now we need to look at the `issue` function, which is finally responsible for detecting the actual issue. This function takes the AST node as the first argument and Credo's `issue_meta` as the second. If we detect an issue, it returns the relevant issue information (`issue_for`, or `nil` otherwise).

Remember how the AST of an import with the `only` option looks? We will match against it now:

```sh
{:import, [line: 2, column: 3],
 [{:__aliases__, [line: 2, column: 10], [:MyApp, :Helpers]}, [only: [hello: 0]]]}
```

The first "wrong" variant is when no options are passed to the `import` statement (`import Ecto.Changeset`) at all. We will match with this and return a non-`nil` result:

```elixir
defp issue({:import, meta, [{:__aliases__, _, _}]}, issue_meta) do
  issue_for(issue_meta, meta[:line])
end
```

Another possible violation is when options are given, but they don't contain `only`, for example: `import Ecto.Changeset, except: [from: 3]`. We match the `opts` in the function definition, and then check if it contains the required key. If not, we add an issue.

```elixir
defp issue({:import, meta, [{:__aliases__, _, _}, opts]}, issue_meta) do
  if Keyword.has_key?(opts, :only), do: nil, else: issue_for(issue_meta, meta[:line])
end
```

We also need one definition for all other cases. We pass the AST nodes to the function, including ones not related to the import. These nodes cannot be the cause of the issue we are looking for, so it's a simple "catch-all" variant:

```elixir
defp issue(_, _), do: nil
```

The last thing in the module is the `issue_for` functions. These take the meta generated by `IssueMeta.for` and pass it to Credo's `format_issue` function, along with the message and the line number.

```elixir
defp issue_for(issue_meta, line_no) do
  format_issue(
    issue_meta,
    message: "Use :only with import statements",
    line_no: line_no
  )
end
```

We can run our tests now to verify that they pass: the check detects issues correctly.

### Enabling the Credo Check in Elixir

As we know that our check works fine (because our tests pass), we can now run `mix credo` on our project. However, even though we know the code is using a "bare import", Credo is still not reporting the issue! To fix that, we need to add our custom check to the Credo config first. In this project, we don't have the config file yet: we have to create it with `mix credo gen.config`.

This will create quite a big `.credo.exs` file with the default configuration. We can skip most of it. Just find a `requires` key and add the path to our check there:

```elixir
requires: ["lib/credo/precise_imports.ex"],
```

Then we need to add the check to the `enabled` section:

```elixir
checks: %{
  enabled: [
    {Credo.Check.Readability.PreciseImports, []},
    [...]
```

With that in place, we can now run `mix credo` again.

```sh
$ mix credo
Checking 1 source file ...

  Code Readability
┃
┃ [R] → Use :only with import statements
┃       lib/my_app.ex:3 #(MyApp)
```

If we change it to use `only`, the check will pass. If we change it to use `except`, but not `only`, the check will fail again (we should probably add a test for this).

### Improving Our Explanation for Credo

One last thing we should do is to improve the explanation we passed as an option to `use Credo.Check`. Change it to something more descriptive:

```elixir
defmodule Credo.Check.Readability.PreciseImports do
  @moduledoc false
  use Credo.Check,
    base_priority: :normal,
    explanations: [
      check: """
      Using bare import statements, without specifying what we are
      importing makes it hard to reason about from where the function
      comes from...
      """
    ]
```

Now it will look better when we run `mix credo explain`:

```sh
$ mix credo explain lib/my_app.ex:3

  MyApp
┃
┃   [R] Category: readability
┃    →  Priority: normal
┃
┃       Use :only with import statements
┃       lib/my_app.ex:3 (MyApp)
┃
┃    __ CODE IN QUESTION
┃
┃     1 defmodule MyApp do
┃     2   @moduledoc false
┃     3   import MyApp.Helpers, except: [hello: 0]
┃     4 end
┃     5
┃
┃    __ WHY IT MATTERS
┃
┃       Using bare import statements, without specifying what we are
┃       importing makes it hard to reason about from where the function
┃       comes from...
```

And that's it!

## Wrapping Up

In this post, we learned how to create a Credo check to enforce a custom code rule. We discussed using tokenization and AST generation to match bad code. Finally, we ran some tests and enabled the Credo check in Elixir.

Happy static analyzing!

**P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, [subscribe to our Elixir Alchemy newsletter and never miss a single post](/elixir-alchemy)!**