Adding Custom Checks
There comes a time when Credo does not feature the check you need or where you want to test a project- or domain-specific aspect of your codebase.
This is when you should consider implementing a Custom Check.
Custom checks are simply modules implementing the Credo.Check
behaviour, which most of the time means that it is a module with a run/2
function returning a list of Credo.Issue
structs:
# lib/checks/my_check.ex
defmodule MyProject.Checks.MyCheck do
use Credo.Check
def run(%SourceFile{} = source_file, params) do
#
end
end
Check Credo.Check
for more technical information.
Our first check: Policing module attributes
Sometimes the conventions for names of module attributes change within a development team and you want to encourage people to stop using the old naming scheme for module attributes to avoid endless bikeshedding about whether or not the new naming policy was needed in the first place.
So, let's implement a check for this completely made up scenario!
Minimal check & config
First, we add the necessary Elixir module for the check and, for now, just return an empty list of issues.
# lib/my_project/checks/reject_module_attributes.ex
defmodule MyProject.Checks.RejectModuleAttributes do
# Set up the behaviour and make this module a "check":
use Credo.Check
# The minimum each check has to implement is a `run/2` function which returns the found issues:
def run(source_file, params \\ []) do
[]
end
end
To run our new check, we also need to add the necessary :requires
and :checks
in our Credo config file:
# .credo.exs
%{
configs: [
%{
name: "default",
requires: ["./lib/my_project/checks/**/*.ex"],
checks: [
{MyProject.Checks.RejectModuleAttributes, []}
]
}
]
}
This tells Credo to require all files in our checks directory and to enable the RejectModuleAttributes
check.
Getting it working
For a first implementation, our check should look into all modules and report "violations" against a list of rejected names.
# lib/my_project/checks/reject_module_attributes.ex
defmodule MyProject.Checks.RejectModuleAttributes do
use Credo.Check
# Let's say we want to report module attributes named `@checkdoc`
@rejected_names [:checkdoc]
def run(source_file, params \\ []) do
# IssueMeta helps keeping track of the source file and the check's params
# (technically, it's just a custom tagged tuple)
issue_meta = IssueMeta.for(source_file, params)
# we'll walk the `source_file`'s AST and look for module attributes matching `@rejected_names`
Credo.Code.prewalk(source_file, &traverse(&1, &2, @rejected_names, issue_meta))
end
# This matches on the AST structure of module attributes.
defp traverse({:@, _, [{name, meta, [_string]} | _]} = ast, issues, rejected_names, issue_meta) do
if Enum.member?(rejected_names, name) do
{ast, issues ++ issue_for(name, meta[:line], issue_meta)}
else
{ast, issues}
end
end
# For all AST nodes not matching the pattern above, we simply do nothing:
defp traverse(ast, issues, _rejected_names, _issue_meta) do
{ast, issues}
end
defp issue_for(trigger, line_no, issue_meta) do
format_issue(
issue_meta,
message: "There should be no `@#{trigger}` module attributes.",
trigger: "@#{trigger}",
line_no: line_no
)
end
end
Traversal of the AST is done via Credo.Code.prewalk/2
, which is a light wrapper around Macro.prewalk/3
, taking a Credo.SourceFile
struct instead of an AST.
You can use Code.string_to_quoted!/1
to look at the AST structure for code snippets:
iex> Code.string_to_quoted!("@my_attribute 23")
{:@, [line: 1], [{:my_attribute, [line: 1], [23]}]}
Adding configuration parameters
Next, we should have a config parameter which allows us to define which module attribute names are no longer allowed.
defmodule MyProject.Checks.RejectModuleAttributes do
# To add a parameter, we use the `:param_defaults` keyword with `use Credo.Check`:
use Credo.Check, param_defaults: [reject: [:checkdoc]]
def run(source_file, params \\ []) do
# To get a parameter, we use `Params.get/3`, which returns the given parameter from the config
# or the default we registered above:
reject = Params.get(params, :reject, __MODULE__)
issue_meta = IssueMeta.for(source_file, params)
Credo.Code.prewalk(source_file, &traverse(&1, &2, reject, issue_meta))
end
# ...
end
We can now use .credo.exs
to configure the :reject
param.
If the param is not declared ...
{MyProject.Checks.RejectModuleAttributes, []}
... then the default from your check is used.
If the param is declared, it overwrites the default, meaning that this ...
{MyProject.Checks.RejectModuleAttributes, [reject: [:shortdoc]]}
... forbids @shortdoc
, but allows @checkdoc
again.
Our final .credo.exs
might look something like this:
# .credo.exs
%{
configs: [
%{
name: "default",
requires: ["./lib/my_project/checks/**/*.ex"],
checks: [
{MyProject.Checks.RejectModuleAttributes, [reject: [:checkdoc, :other_attr]]}
]
}
]
}
Finalizing the check
To really make this a full-fledged Credo check, we have to configure its priority, category and describe what it does (you can find a description of the options in Credo.Check
).
defmodule MyProject.Checks.RejectModuleAttributes do
use Credo.Check,
base_priority: :high,
category: :readability,
param_defaults: [reject: []],
explanations: [
check: """
Look, sometimes the policies for names of module attributes change.
We want to make sure that all module attributes adhere to the newest standards of ACME Corp.
We do not want to discuss this policy, we just want to stop you from using the old
module attributes :)
""",
params: [reject: "This check warns about module attributes with any of the given names."]
]
# ...
end
You can now use Credo's explain
command ...
$ mix credo explain MyProject.Checks.RejectModuleAttributes
... to show a description of your new check:
MyProject.Checks.MyIExPry
┃
┃ [R] Category: readability
┃ ↗ Priority: high
┃
┃ __ WHY IT MATTERS
┃
┃ Look, sometimes the policies for names of module attributes change.
┃ We want to make sure that all module attributes adhere to the newest standards of ACME Corp.
┃
┃ We do not want to discuss this policy, we just want to stop you from using the old
┃ module attributes :)
┃
┃ __ CONFIGURATION OPTIONS
┃
┃ To configure this check, use this tuple
┃
┃ {MyProject.Checks.RejectModuleAttributes, <params>}
┃
┃ with <params> being false or any combination of these keywords:
┃
┃ reject: Names of module attributes that are no longer allowed
┃ (defaults to [])
┃
And that's it. Here's the final check:
defmodule MyProject.Checks.RejectModuleAttributes do
use Credo.Check,
base_priority: :high,
category: :readability,
param_defaults: [reject: []],
explanations: [
check: """
Look, sometimes the policies for names of module attributes change.
We want to make sure that all module attributes adhere to the newest standards of ACME Corp.
We do not want to discuss this policy, we just want to stop you from using the old
module attributes :)
""",
params: [reject: "Names of module attributes that are no longer allowed"]
]
def run(source_file, params \\ []) do
reject = Params.get(params, :reject, __MODULE__)
issue_meta = IssueMeta.for(source_file, params)
Credo.Code.prewalk(source_file, &traverse(&1, &2, reject, issue_meta))
end
defp traverse({:@, _, [{name, meta, [_string]} | _]} = ast, issues, rejected_names, issue_meta) do
if Enum.member?(rejected_names, name) do
{ast, issues ++ issue_for(name, meta[:line], issue_meta)}
else
{ast, issues}
end
end
defp traverse(ast, issues, _rejected_names, _issue_meta) do
{ast, issues}
end
defp issue_for(trigger, line_no, issue_meta) do
format_issue(
issue_meta,
message: "There should be no `@#{trigger}` module attributes.",
trigger: trigger,
line_no: line_no
)
end
end
Next, let's see how we can write tests for our custom check!