Writing a Custom Credo Check in Elixir
Paweł Świątkowski
Posted on September 6, 2023
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 fromMyApp
- To ensure some naming conventions, for example, forbidding the
is_
prefix for functions (preferactive?
tois_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
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
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
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.
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.
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}}
]
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]
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
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
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
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
We also need to start the Credo application in our test_helper.ex
:
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.
[
{: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:
> 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]
:
> 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:
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.
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:
{: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:
{:__aliases__, [line: 1, column: 11], [:TestModule]}
In the third step, it's just the name of the module:
:TestModule
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]]
]}
]
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]]]}
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))}
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]
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]]]}
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
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
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
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
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"],
Then we need to add the check to the enabled
section:
checks: %{
enabled: [
{Credo.Check.Readability.PreciseImports, []},
[...]
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)
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...
"""
]
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...
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!
Posted on September 6, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.