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:
Then we will cd
into the newly created my_app
and add Credo to the dependencies in mix.exs
, so it looks like this:
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:
Now when we run mix credo
, we get a nice error:
If we are unsure what that means, we can ask for details:
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):
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:
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.
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:
Instead, we will create the file skeleton ourselves. In the same location, let's start with this:
Write Some Tests in Credo
To check if it works, we need to write some tests. Luckily for us, Credo makes this really easy.
We also need to start the Credo application in our test_helper.ex
:
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.
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:
And when we specify import with 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:
Let's break this down.
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:
Second:
In the third step, it's just the name of the module:
There's nothing here to go deeper into, so we go to the next "sibling" node:
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:
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
.
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.
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:
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:
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.
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:
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.
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:
Then we need to add the check to the enabled
section:
With that in place, we can now run mix credo
again.
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:
Now it will look better when we run mix credo explain
:
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!