Writing a Custom Credo Check in Elixir

katafrakt

Paweł Świątkowski

Posted on September 6, 2023

Writing a Custom Credo Check in Elixir

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:

$ mix new my_app
Enter fullscreen mode Exit fullscreen mode

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

defp deps do
  [{:credo, "~> 1.7", only: [:dev, :test], runtime: false}]
end
Enter fullscreen mode Exit fullscreen mode

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:

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

  def test do
    x = 1; y = 2
  end
end
Enter fullscreen mode Exit fullscreen mode

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

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.
Enter fullscreen mode Exit fullscreen mode

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

$ 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.
Enter fullscreen mode Exit fullscreen mode

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.

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

[
  {: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}}
]
Enter fullscreen mode Exit fullscreen mode

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:

import Ecto.Changeset, only: [cast: 4]
Enter fullscreen mode Exit fullscreen mode

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.

defmodule MyApp do
  @moduledoc false
  import Ecto.Changeset
end
Enter fullscreen mode Exit fullscreen mode

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:

$ mix credo.gen.check lib/credo/precise_imports.ex
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

Write Some Tests in Credo

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

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
Enter fullscreen mode Exit fullscreen mode

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

Credo.Application.start([], [])
Enter fullscreen mode Exit fullscreen mode

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.

[
  {: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}}
]
Enter fullscreen mode Exit fullscreen mode

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:

> 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]}]}
    ]
  ]}}
Enter fullscreen mode Exit fullscreen mode

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

> 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]]
       ]}
    ]
  ]}}
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

Let's break this down.

def run(source_file, params) do
  source_file
  |> Credo.Code.prewalk(&traverse(&1, &2, IssueMeta.for(source_file, params)))
end
Enter fullscreen mode Exit fullscreen mode

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:

{: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]]
      ]}
   ]
 ]}
Enter fullscreen mode Exit fullscreen mode

Second:

{:__aliases__, [line: 1, column: 11], [:TestModule]}
Enter fullscreen mode Exit fullscreen mode

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

:TestModule
Enter fullscreen mode Exit fullscreen mode

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

[
  do: {:import, [line: 2, column: 3],
   [
     {:__aliases__, [line: 2, column: 10], [:MyApp, :Helpers]},
     [only: [hello: 0]]
   ]}
]
Enter fullscreen mode Exit fullscreen mode

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:

{:import, [line: 2, column: 3],
 [{:__aliases__, [line: 2, column: 10], [:MyApp, :Helpers]}, [only: [hello: 0]]]}
Enter fullscreen mode Exit fullscreen mode

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

defp traverse(ast, issues, issue_meta), do: {ast, add_issue(issues, issue(ast, issue_meta))}
Enter fullscreen mode Exit fullscreen mode

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.

defp add_issue(issues, nil), do: issues
defp add_issue(issues, issue), do: [issue | issues]
Enter fullscreen mode Exit fullscreen mode

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:

{:import, [line: 2, column: 3],
 [{:__aliases__, [line: 2, column: 10], [:MyApp, :Helpers]}, [only: [hello: 0]]]}
Enter fullscreen mode Exit fullscreen mode

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:

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

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.

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
Enter fullscreen mode Exit fullscreen mode

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:

defp issue(_, _), do: nil
Enter fullscreen mode Exit fullscreen mode

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.

defp issue_for(issue_meta, line_no) do
  format_issue(
    issue_meta,
    message: "Use :only with import statements",
    line_no: line_no
  )
end
Enter fullscreen mode Exit fullscreen mode

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:

requires: ["lib/credo/precise_imports.ex"],
Enter fullscreen mode Exit fullscreen mode

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

checks: %{
  enabled: [
    {Credo.Check.Readability.PreciseImports, []},
    [...]
Enter fullscreen mode Exit fullscreen mode

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

$ mix credo
Checking 1 source file ...

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

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:

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...
      """
    ]
Enter fullscreen mode Exit fullscreen mode

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

$ 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...
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
katafrakt
Paweł Świątkowski

Posted on September 6, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related