elixir

Writing a Custom Credo Check in Elixir

Paweł Świątkowski

Paweł Świątkowski on

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:

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

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

Shell
$ 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.

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

Shell
[ {: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:

Shell
$ 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.

Shell
[ {: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:

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

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

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

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

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

Shell
:TestModule

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

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

Shell
{: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, 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:

Shell
{: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.

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

Shell
$ 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!

Paweł Świątkowski

Paweł Świątkowski

Our guest author Paweł is a mostly backend-focused developer, always looking for new things to try and learn. When he's not looking at a screen, he can be found playing curling (in winter) or hiking (in summer).

All articles by Paweł Świątkowski

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps