- Published on
Making a Custom Credo Rule
- Authors
- Name
- Gabriel Perales
- @g_perales
Making a Custom Credo Rule
Credo is a static code analysis tool for Elixir. It's a powerful tool that can help you catch bugs and improve your code quality.
In this post, we'll create a custom Credo rule to enforce pattern matching by struct instead of using maps with atom keys. This is a common practice in Elixir and a good way to ensure that your code is more readable and maintainable.
In our company's codebase, we have many functions that receive a map as an argument and pattern match on it. While this isn't inherently problematic, when we know the map will always be a struct, we can make the code more readable and type-safe by pattern matching using the struct name.
This is an example of what we'd like to catch:
# ❌ Not preferred
def get_role(%{role: role}) do
role
end
# ✅ Preferred
def get_role(%User{role: role}) do
role
end
# ✅ Also preferred
def get_role(%User{} = user) do
user.role
end
Disclaimer: This Credo check could be controversial, as there are cases where we legitimately have maps with atom keys that aren't structs, such as in configuration maps or LiveView assigns. We've decided to give it a try and are disabling this rule in LiveView components and some other specific cases. This post focuses more on the process of creating a custom rule than on the rule itself.
Start a new example project
mix new custom_credo_check
Creating the Rule
First, ensure that you have Credo installed in your project. If you don't, add it to your project by adding the following to your mix.exs
file:
defp deps do
[
{:credo, "~> 1.7"}
]
end
and then run mix deps.get
to install the dependency.
Next, use the Credo generator to create a new rule:
mix credo.gen.check lib/credo/check/readability/prefer_struct_matching.ex
This will create a new file in lib/credo/check/readability/prefer_struct_matching.ex
with some starter content that we'll use as a foundation for our custom rule.
Creating a test to check if the rule works
We'll create a test to check if the rule works. We'll create a new file in test/credo/check/readability/prefer_struct_matching_test.ex
and add the following content:
defmodule Credo.Check.Readability.PreferStructMatchingTest do
use Credo.Test.Case
alias Credo.Check.Readability.PreferStructMatching
describe "PreferStructMatching" do
test "it should report an issue when pattern matching a parameter using a map with atom keys" do
"""
def get_role(%{role: role}) do
role
end
"""
|> to_source_file()
|> run_check(PreferStructMatching)
|> assert_issue()
end
end
end
If we run mix test
, we'll see that the test is failing. This is good, we haven't implemented the rule yet.
Implementing the Rule
Our rule will be straightforward. It will check if a function is pattern matching a parameter using a map with atom keys. If so, we'll flag it as an issue.
To implement this rule, we need to:
- Get the function parameters
- Check if any parameters are pattern matching a map with atom keys
- Create an issue when we find such a pattern
By examining the generated file, we see that we need to implement the run/2
function, which receives the source file and any parameters specified in the .credo.exs
configuration.
We'll use the Credo.Code.prewalk/2
function to traverse the Abstract Syntax Tree (AST) of the source file, looking for function definitions:
def run(%SourceFile{} = source_file, params) do
issue_meta = IssueMeta.for(source_file, params)
Credo.Code.prewalk(source_file, &traverse(&1, &2, issue_meta))
end
The traverse/3
function will examine each node in the AST, identifying function definitions and checking their parameters. This function will return a tuple containing the AST and any issues found. Let's implement it adding some debug prints to see what's happening:
defp traverse({:def, _meta, [{_fn_name, _meta, fn_args}, fn_body]} = ast, issues, issue_meta) do
IO.puts("Found a function definition: #{inspect(ast)}")
IO.puts("Function args: #{inspect(fn_args)}")
IO.puts("Function body: #{inspect(fn_body)}")
{ast, issues}
end
defp traverse(ast, issues, _issue_meta), do: {ast, issues}
With just a few lines, we already have access to the function parameters and body. Now we need a function to check if any parameters are pattern matching by a map with atom keys.
The AST pattern we're looking for is something like {:%{}, _meta, map_fields}
, where any of the map_fields
is an atom. Let's create a helper function for this check:
defp check_arg_pattern({:%{}, _, fields} = _arg, meta, issue_meta) do
if Enum.any?(fields, fn {key, _value} -> is_atom(key) end) do
[issue_for(issue_meta, meta[:line], "direct map pattern with atom keys")]
else
[]
end
end
defp check_arg_pattern(_, _, _), do: []
Finally, we can update our traverse/3
function to use this helper:
defp traverse({:def, meta, [{_fn_name, _meta, fn_args}, _fn_body]} = ast, issues, issue_meta) do
new_issues =
fn_args
|> Enum.flat_map(fn arg -> check_arg_pattern(arg, meta, issue_meta) end)
{ast, issues ++ new_issues}
end
defp traverse(ast, issues, _issue_meta), do: {ast, issues}
With this implementation, our Credo rule will identify functions that pattern match on maps with atom keys, encouraging developers to use structs instead when appropriate.
If we run mix test
, we'll see that the test is passing.
We can update our file test/credo/check/readability/prefer_struct_matching_test.ex
to add a test to check if the rule works when a parameter is pattern matched with an struct:
test "it should not report an issue when pattern matching by a struct with atom keys" do
"""
def get_role(%User{role: role}) do
role
end
"""
|> to_source_file()
|> run_check(PreferStructMatching)
|> refute_issues()
end
If we run mix test
, we'll see that the test is passing. Now we can add more tests to check if the rule works in different scenarios.
There are still some edge cases that we need to handle, such as when the map is pattern matched with an assignment, or when the function has guards, but we want to keep this post focused on the process of creating a custom rule and keep the code simple.
You can find the complete code of this post in this GitHub repository.